Skip to content

Commit

Permalink
Add module for parsing and validating webhook events from Braintree (#…
Browse files Browse the repository at this point in the history
…113)

* 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 #98

* Refactor tests to use sample fixture(s)
  • Loading branch information
treble37 authored Jul 12, 2021
1 parent 555e3e7 commit fee03b9
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 1 deletion.
23 changes: 23 additions & 0 deletions lib/webhook.ex
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions lib/webhook/digest.ex
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions lib/webhook/validation.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
44 changes: 44 additions & 0 deletions test/support/webhook_test_helper.ex
Original file line number Diff line number Diff line change
@@ -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>#{source_merchant_id}</source-merchant-id>"
else
nil
end

~s"""
<notification>
<timestamp type="datetime">#{datetime}</timestamp>
<kind>#{kind}</kind>
#{source_merchant_xml}
<subject>
#{subject_sample_xml(kind, data)}
</subject>
</notification>
"""
end

defp subject_sample_xml(_kind, _id) do
~s"""
<check type="boolean">true</check>
"""
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
41 changes: 41 additions & 0 deletions test/webhook/digest_test.exs
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions test/webhook/validation_test.exs
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions test/webhook_test.exs
Original file line number Diff line number Diff line change
@@ -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" =>
"<notification>\n <timestamp type=\"datetime\">2021-06-24T23:41:58Z</timestamp>\n <kind>check</kind>\n \n <subject>\n <check type=\"boolean\">true</check>\n\n </subject>\n</notification>\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

0 comments on commit fee03b9

Please sign in to comment.