diff --git a/lib/webhook.ex b/lib/webhook.ex
new file mode 100644
index 0000000..36e1a96
--- /dev/null
+++ b/lib/webhook.ex
@@ -0,0 +1,23 @@
+defmodule Braintree.Webhook do
+ @moduledoc """
+ This module provides convenience methods for parsing Braintree webhook payloads.
+ """
+ alias Braintree.Webhook.Validation
+
+ @doc """
+ Return a map containing the payload and signature from the braintree webhook event.
+ """
+ @spec parse(String.t() | nil, String.t() | nil) :: {:ok, map} | {:error, String.t()}
+ def parse(nil, _payload), do: {:error, "Signature cannot be nil"}
+ def parse(_sig, nil), do: {:error, "Payload cannot be nil"}
+
+ def parse(sig, payload) do
+ with :ok <- Validation.validate_signature(sig, payload),
+ {:ok, decoded} <- Base.decode64(payload, ignore: :whitespace) do
+ {:ok, %{"payload" => decoded, "signature" => sig}}
+ else
+ :error -> {:error, "Could not decode payload"}
+ {:error, error_msg} -> {:error, error_msg}
+ end
+ end
+end
diff --git a/lib/webhook/digest.ex b/lib/webhook/digest.ex
new file mode 100644
index 0000000..5ccd810
--- /dev/null
+++ b/lib/webhook/digest.ex
@@ -0,0 +1,35 @@
+defmodule Braintree.Webhook.Digest do
+ @moduledoc """
+ This module provides convenience methods to help validate Braintree signatures and associated payloads for webhooks.
+ """
+
+ @spec secure_compare(String.t(), String.t()) :: boolean()
+ @doc """
+ A wrapper function that does a secure comparision accounting for timing attacks.
+ """
+ def secure_compare(left, right) when is_binary(left) and is_binary(right),
+ do: Plug.Crypto.secure_compare(left, right)
+
+ def secure_compare(_, _), do: false
+
+ @spec hexdigest(String.t() | nil, String.t() | nil) :: String.t()
+ @doc """
+ Returns the message as a hex-encoded string to validate it matches the signature from the braintree webhook event.
+ """
+ def hexdigest(nil, _), do: ""
+ def hexdigest(_, nil), do: ""
+
+ def hexdigest(private_key, message) do
+ key_digest = :crypto.hash(:sha, private_key)
+
+ hmac(:sha, key_digest, message)
+ |> Base.encode16(case: :lower)
+ end
+
+ # TODO: remove when we require OTP 22
+ if System.otp_release() >= "22" do
+ defp hmac(digest, key, data), do: :crypto.mac(:hmac, digest, key, data)
+ else
+ defp hmac(digest, key, data), do: :crypto.hmac(digest, key, data)
+ end
+end
diff --git a/lib/webhook/validation.ex b/lib/webhook/validation.ex
new file mode 100644
index 0000000..035d216
--- /dev/null
+++ b/lib/webhook/validation.ex
@@ -0,0 +1,46 @@
+defmodule Braintree.Webhook.Validation do
+ @moduledoc """
+ This module provides convenience methods to help validate Braintree signatures and associated payloads for webhooks.
+ """
+ alias Braintree.Webhook.Digest
+
+ @spec validate_signature(String.t() | nil, String.t() | nil) :: :ok | {:error, String.t()}
+ @doc """
+ Validate the webhook signature and payload from braintree.
+ """
+ def validate_signature(nil, _payload), do: {:error, "Signature cannot be nil"}
+ def validate_signature(_sig, nil), do: {:error, "Payload cannot be nil"}
+
+ def validate_signature(sig, payload) do
+ sig
+ |> matching_sig_pair()
+ |> compare_sig_pair(payload)
+ end
+
+ defp matching_sig_pair(sig_string) do
+ sig_string
+ |> String.split("&")
+ |> Enum.filter(&String.contains?(&1, "|"))
+ |> Enum.map(&String.split(&1, "|"))
+ |> Enum.find([], fn [public_key, _signature] -> public_key == braintree_public_key() end)
+ end
+
+ defp compare_sig_pair([], _), do: {:error, "No matching public key"}
+
+ defp compare_sig_pair([_public_key, sig], payload) do
+ [payload, payload <> "\n"]
+ |> Enum.any?(fn pload -> secure_compare(sig, pload) end)
+ |> case do
+ true -> :ok
+ false -> {:error, "Signature does not match payload, one has been modified"}
+ end
+ end
+
+ defp secure_compare(signature, payload) do
+ payload_signature = Digest.hexdigest(braintree_private_key(), payload)
+ Digest.secure_compare(signature, payload_signature)
+ end
+
+ defp braintree_public_key(), do: Braintree.fetch_env!(:public_key)
+ defp braintree_private_key(), do: Braintree.fetch_env!(:private_key)
+end
diff --git a/mix.exs b/mix.exs
index 290c25d..0b1ad3f 100644
--- a/mix.exs
+++ b/mix.exs
@@ -8,7 +8,7 @@ defmodule Braintree.Mixfile do
app: :braintree,
version: @version,
elixir: "~> 1.7",
- elixirc_paths: ["lib"],
+ elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
test_coverage: [tool: ExCoveralls],
description: description(),
@@ -74,4 +74,8 @@ defmodule Braintree.Mixfile do
]
]
end
+
+ # Specifies which paths to compile per environment.
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
end
diff --git a/test/support/webhook_test_helper.ex b/test/support/webhook_test_helper.ex
new file mode 100644
index 0000000..fe7685c
--- /dev/null
+++ b/test/support/webhook_test_helper.ex
@@ -0,0 +1,44 @@
+defmodule Braintree.Test.Support.WebhookTestHelper do
+ @moduledoc false
+
+ alias Braintree.Webhook.Digest
+
+ def sample_notification(kind, id, source_merchant_id \\ nil) do
+ payload = Base.encode64(sample_xml(kind, id, source_merchant_id))
+
+ signature_string =
+ "#{braintree_public_key()}|#{Digest.hexdigest(braintree_private_key(), payload)}"
+
+ %{"bt_signature" => signature_string, "bt_payload" => payload}
+ end
+
+ def sample_xml(kind, data, datetime, source_merchant_id \\ nil) do
+ source_merchant_xml =
+ if source_merchant_id == nil do
+ "#{source_merchant_id}"
+ else
+ nil
+ end
+
+ ~s"""
+
+ #{datetime}
+ #{kind}
+ #{source_merchant_xml}
+
+ #{subject_sample_xml(kind, data)}
+
+
+ """
+ end
+
+ defp subject_sample_xml(_kind, _id) do
+ ~s"""
+ true
+ """
+ end
+
+ # TODO: need to update these to pull from test config found in test.secret.exs...
+ defp braintree_public_key(), do: Braintree.get_env(:public_key, "public_key")
+ defp braintree_private_key(), do: Braintree.get_env(:private_key, "private_key")
+end
diff --git a/test/webhook/digest_test.exs b/test/webhook/digest_test.exs
new file mode 100644
index 0000000..c61fcfe
--- /dev/null
+++ b/test/webhook/digest_test.exs
@@ -0,0 +1,41 @@
+defmodule Braintree.Webhook.DigestTest do
+ use ExUnit.Case, async: true
+
+ alias Braintree.Webhook.Digest
+
+ describe "WebhookDigest#hexdigest/2" do
+ test "returns the sha1 hmac of the input string (test case 6 from RFC 2202)" do
+ private_key = String.duplicate("\xaa", 80)
+ data = "Test Using Larger Than Block-Size Key - Hash Key First"
+ assert Digest.hexdigest(private_key, data) == "aa4ae5e15272d00e95705637ce8a3b55ed402112"
+ end
+
+ test "returns the sha1 hmac of the input string (test case 7 from RFC 2202)" do
+ private_key = String.duplicate("\xaa", 80)
+ data = "Test Using Larger Than Block-Size Key and Larger Than One Block-Size Data"
+ assert Digest.hexdigest(private_key, data) == "e8e99d0f45237d786d6bbaa7965c7808bbff1a91"
+ end
+
+ test "doesn't blow up if message is nil" do
+ assert Digest.hexdigest("key", nil) == ""
+ end
+
+ test "doesn't blow up if key is nil" do
+ assert Digest.hexdigest(nil, "key") == ""
+ end
+ end
+
+ describe "Digest#secure_compare/2" do
+ test "returns true if two strings are equal" do
+ assert Digest.secure_compare("A_string", "A_string") == true
+ end
+
+ test "returns false if two strings are different and the same length" do
+ assert Digest.secure_compare("A_string", "A_strong") == false
+ end
+
+ test "returns false if one is a prefix of the other" do
+ assert Digest.secure_compare("A_string", "A_string_that_is_longer") == false
+ end
+ end
+end
diff --git a/test/webhook/validation_test.exs b/test/webhook/validation_test.exs
new file mode 100644
index 0000000..a76d71f
--- /dev/null
+++ b/test/webhook/validation_test.exs
@@ -0,0 +1,38 @@
+defmodule Braintree.Webhook.ValidationTest do
+ use ExUnit.Case, async: true
+
+ alias Braintree.Webhook.Validation
+ alias Braintree.Test.Support.WebhookTestHelper
+
+ describe "Validation#validate_signature/2" do
+ setup do
+ %{"bt_payload" => _payload, "bt_signature" => _signature} =
+ WebhookTestHelper.sample_notification("check", nil, "source_merchant_123")
+ end
+
+ test "returns :ok with valid signature and payload", %{
+ "bt_payload" => payload,
+ "bt_signature" => signature
+ } do
+ assert Validation.validate_signature(signature, payload) == :ok
+ end
+
+ test "returns error tuple with invalid signature", %{"bt_payload" => payload} do
+ assert Validation.validate_signature("fake_signature", payload) ==
+ {:error, "No matching public key"}
+ end
+
+ test "returns error tuple with invalid payload", %{"bt_signature" => signature} do
+ assert Validation.validate_signature(signature, "fake_payload") ==
+ {:error, "Signature does not match payload, one has been modified"}
+ end
+
+ test "returns error tuple with nil payload", %{"bt_signature" => signature} do
+ assert Validation.validate_signature(signature, nil) == {:error, "Payload cannot be nil"}
+ end
+
+ test "returns error tuple with nil signature", %{"bt_payload" => payload} do
+ assert Validation.validate_signature(nil, payload) == {:error, "Signature cannot be nil"}
+ end
+ end
+end
diff --git a/test/webhook_test.exs b/test/webhook_test.exs
new file mode 100644
index 0000000..5c84a60
--- /dev/null
+++ b/test/webhook_test.exs
@@ -0,0 +1,44 @@
+defmodule Braintree.WebhookTest do
+ use ExUnit.Case, async: true
+
+ alias Braintree.Webhook
+ alias Braintree.Test.Support.WebhookTestHelper
+
+ describe "Webhook#parse/2" do
+ setup do
+ %{"bt_payload" => _payload, "bt_signature" => _signature} =
+ WebhookTestHelper.sample_notification("check", nil, "source_merchant_123")
+ end
+
+ test "returns decoded payload tuple with valid signature and payload", %{
+ "bt_payload" => payload,
+ "bt_signature" => signature
+ } do
+ assert Webhook.parse(signature, payload) ==
+ {:ok,
+ %{
+ "payload" =>
+ "\n 2021-06-24T23:41:58Z\n check\n \n \n true\n\n \n\n",
+ "signature" => "public_key|4261b2771e7852348af5103d7f98b6148bb9ad1b"
+ }}
+ end
+
+ test "returns error tuple with invalid signature", %{"bt_payload" => payload} do
+ assert Webhook.parse("fake_signature", payload) ==
+ {:error, "No matching public key"}
+ end
+
+ test "returns error tuple with invalid payload", %{"bt_signature" => signature} do
+ assert Webhook.parse(signature, "fake_payload") ==
+ {:error, "Signature does not match payload, one has been modified"}
+ end
+
+ test "returns error tuple with nil payload", %{"bt_signature" => signature} do
+ assert Webhook.parse(signature, nil) == {:error, "Payload cannot be nil"}
+ end
+
+ test "returns error tuple with nil signature", %{"bt_payload" => payload} do
+ assert Webhook.parse(nil, payload) == {:error, "Signature cannot be nil"}
+ end
+ end
+end