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
- 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 sorentwo#98
  • Loading branch information
treble37 committed Jul 3, 2021
1 parent 555e3e7 commit 8a9ba52
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 0 deletions.
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

@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
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. 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
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. 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
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
39 changes: 39 additions & 0 deletions test/webhook/validation_test.exs
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions test/webhook_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 8a9ba52

Please sign in to comment.