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