forked from sorentwo/braintree-elixir
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 sorentwo#98
- Loading branch information
Showing
6 changed files
with
233 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |