From 8a9ba5233f8fe61b42880ce7b93da1e1a1e46883 Mon Sep 17 00:00:00 2001
From: Bruce P <treble37@users.noreply.github.com>
Date: Thu, 1 Jul 2021 15:51:53 -0700
Subject: [PATCH] Add module for parsing and validating webhook events from
 Braintree

- Ensure compatiblity with pre-OTP-22 version as well as OTP-22+
- Right now, Braintree.Webhook#parse/2 returns the signature and decoded
  payload (an xml document embedded in a string) from Braintree for later post-processing
- Future work envision handling the webhook notification payload parsing
  in a more standardized way
- Related to https://github.com/sorentwo/braintree-elixir/issues/98
---
 lib/webhook.ex                   | 23 +++++++++++++++
 lib/webhook/digest.ex            | 35 +++++++++++++++++++++++
 lib/webhook/validation.ex        | 46 ++++++++++++++++++++++++++++++
 test/webhook/digest_test.exs     | 41 ++++++++++++++++++++++++++
 test/webhook/validation_test.exs | 39 +++++++++++++++++++++++++
 test/webhook_test.exs            | 49 ++++++++++++++++++++++++++++++++
 6 files changed, 233 insertions(+)
 create mode 100644 lib/webhook.ex
 create mode 100644 lib/webhook/digest.ex
 create mode 100644 lib/webhook/validation.ex
 create mode 100644 test/webhook/digest_test.exs
 create mode 100644 test/webhook/validation_test.exs
 create mode 100644 test/webhook_test.exs

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" =>
+                    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<notification>\n  <kind>check</kind>\n  <timestamp type=\"datetime\">2021-06-24T23:41:58Z</timestamp>\n  <subject>\n    <check type=\"boolean\">true</check>\n  </subject>\n</notification>\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