diff --git a/lib/webhook.ex b/lib/webhook.ex new file mode 100644 index 0000000..5a79429 --- /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 + + @spec parse(String.t() | nil, String.t() | nil) :: {:ok, map} | {:error, String.t()} + @doc """ + Return a map containing the payload and signature from the braintree webhook event. + """ + 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..5af1add --- /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. Initially based on the Ruby library (https://github.com/braintree/braintree_ruby). + """ + + @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..05570ae --- /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. Initially based on the Ruby library (https://github.com/braintree/braintree_ruby). + """ + 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.get_env(:public_key) + defp braintree_private_key(), do: Braintree.get_env(: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..d01a09d --- /dev/null +++ b/test/webhook/validation_test.exs @@ -0,0 +1,39 @@ +defmodule Braintree.Webhook.ValidationTest do + use ExUnit.Case, async: true + + alias Braintree.Webhook.Validation + + describe "Validation#validate_signature/2" do + setup do + payload = + "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPG5vdGlm\naWNhdGlvbj4KICA8a2luZD5jaGVjazwva2luZD4KICA8dGltZXN0YW1wIHR5\ncGU9ImRhdGV0aW1lIj4yMDIxLTA2LTI0VDIzOjQxOjU4WjwvdGltZXN0YW1w\nPgogIDxzdWJqZWN0PgogICAgPGNoZWNrIHR5cGU9ImJvb2xlYW4iPnRydWU8\nL2NoZWNrPgogIDwvc3ViamVjdD4KPC9ub3RpZmljYXRpb24+Cg==\n" + + signature = + "93jkrvc4dhwgggvb|18e3da6df975b51672fb66db8c17b6a587119cf4&bbqs7w3x5mxpn2kt|c4aeed13c841092f92a52c30f4102bcdc9f56b9e&d4wrnb8q3nth8c6z|787357b629927407c406371cce80d3e2b803b539&z5zy86h2g96wwk6y|deb53e0857b83c51dd47e7af000b629caf63d4ab" + + %{payload: payload, signature: signature} + end + + test "returns :ok with valid signature and payload", %{payload: payload, signature: signature} do + assert Validation.validate_signature(signature, payload) == :ok + end + + test "returns error tuple with invalid signature", %{payload: payload} do + assert Validation.validate_signature("fake_signature", payload) == + {:error, "No matching public key"} + end + + test "returns error tuple with invalid payload", %{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", %{signature: signature} do + assert Validation.validate_signature(signature, nil) == {:error, "Payload cannot be nil"} + end + + test "returns error tuple with nil signature", %{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..1d740f7 --- /dev/null +++ b/test/webhook_test.exs @@ -0,0 +1,49 @@ +defmodule Braintree.WebhookTest do + use ExUnit.Case, async: true + + alias Braintree.Webhook + + describe "Webhook#parse/2" do + setup do + payload = + "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPG5vdGlm\naWNhdGlvbj4KICA8a2luZD5jaGVjazwva2luZD4KICA8dGltZXN0YW1wIHR5\ncGU9ImRhdGV0aW1lIj4yMDIxLTA2LTI0VDIzOjQxOjU4WjwvdGltZXN0YW1w\nPgogIDxzdWJqZWN0PgogICAgPGNoZWNrIHR5cGU9ImJvb2xlYW4iPnRydWU8\nL2NoZWNrPgogIDwvc3ViamVjdD4KPC9ub3RpZmljYXRpb24+Cg==\n" + + signature = + "93jkrvc4dhwgggvb|18e3da6df975b51672fb66db8c17b6a587119cf4&bbqs7w3x5mxpn2kt|c4aeed13c841092f92a52c30f4102bcdc9f56b9e&d4wrnb8q3nth8c6z|787357b629927407c406371cce80d3e2b803b539&z5zy86h2g96wwk6y|deb53e0857b83c51dd47e7af000b629caf63d4ab" + + %{payload: payload, signature: signature} + end + + test "returns decoded payload tuple with valid signature and payload", %{ + payload: payload, + signature: signature + } do + assert Webhook.parse(signature, payload) == + {:ok, + %{ + "payload" => + "\n\n check\n 2021-06-24T23:41:58Z\n \n true\n \n\n", + "signature" => + "93jkrvc4dhwgggvb|18e3da6df975b51672fb66db8c17b6a587119cf4&bbqs7w3x5mxpn2kt|c4aeed13c841092f92a52c30f4102bcdc9f56b9e&d4wrnb8q3nth8c6z|787357b629927407c406371cce80d3e2b803b539&z5zy86h2g96wwk6y|deb53e0857b83c51dd47e7af000b629caf63d4ab" + }} + end + + test "returns error tuple with invalid signature", %{payload: payload} do + assert Webhook.parse("fake_signature", payload) == + {:error, "No matching public key"} + end + + test "returns error tuple with invalid payload", %{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", %{signature: signature} do + assert Webhook.parse(signature, nil) == {:error, "Payload cannot be nil"} + end + + test "returns error tuple with nil signature", %{payload: payload} do + assert Webhook.parse(nil, payload) == {:error, "Signature cannot be nil"} + end + end +end