| 1 |
|
defmodule WalletIntegrations.CallbackVerifier do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Signature and freshness verification for inbound provider webhooks (ADR 0008). |
| 4 |
|
|
| 5 |
|
## Stripe Webhook Verification |
| 6 |
|
Stripe signs webhook payloads using HMAC-SHA256. |
| 7 |
|
The `Stripe-Signature` header format: |
| 8 |
|
`t=<unix_timestamp>,v1=<signature>[,v0=<legacy_signature>]` |
| 9 |
|
|
| 10 |
|
Verification steps: |
| 11 |
|
1. Extract `t` (timestamp) and `v1` (signature list) from header. |
| 12 |
|
2. Check freshness: `abs(now_unix - t) <= tolerance_seconds`. |
| 13 |
|
3. Compute `HMAC-SHA256(secret, "\#{t}.\#{raw_body}")`. |
| 14 |
|
4. Compare result to each `v1` value using constant-time comparison. |
| 15 |
|
|
| 16 |
|
## Freshness Policy |
| 17 |
|
Default tolerance: 300 seconds (5 minutes). |
| 18 |
|
Configurable via `Application.get_env(:wallet_integrations, :callback_freshness_tolerance, 300)`. |
| 19 |
|
""" |
| 20 |
|
|
| 21 |
|
@default_tolerance_seconds 300 |
| 22 |
|
|
| 23 |
|
@doc """ |
| 24 |
|
Verify a Stripe webhook signature. |
| 25 |
|
Returns `:ok` on success, `{:error, reason}` on failure. |
| 26 |
|
""" |
| 27 |
|
@spec verify_stripe(raw_body :: String.t(), signature_header :: String.t(), secret :: String.t()) :: |
| 28 |
|
:ok | {:error, :invalid_signature | :stale_callback | :malformed_signature} |
| 29 |
|
def verify_stripe(raw_body, signature_header, secret) do |
| 30 |
16 |
with {:ok, timestamp, signatures} <- parse_stripe_signature(signature_header), |
| 31 |
12 |
:ok <- check_freshness(timestamp) do |
| 32 |
10 |
expected = compute_stripe_hmac(secret, timestamp, raw_body) |
| 33 |
|
|
| 34 |
10 |
if Enum.any?(signatures, &constant_time_equal?(&1, expected)) do |
| 35 |
|
:ok |
| 36 |
|
else |
| 37 |
|
{:error, :invalid_signature} |
| 38 |
|
end |
| 39 |
|
end |
| 40 |
|
end |
| 41 |
|
|
| 42 |
|
@doc """ |
| 43 |
|
Verify freshness of an inbound callback by comparing its timestamp to the current time. |
| 44 |
|
`timestamp_unix` is an integer Unix epoch seconds value. |
| 45 |
|
""" |
| 46 |
|
@spec check_freshness(timestamp_unix :: integer()) :: :ok | {:error, :stale_callback} |
| 47 |
|
def check_freshness(timestamp_unix) do |
| 48 |
17 |
tolerance = Application.get_env(:wallet_integrations, :callback_freshness_tolerance, @default_tolerance_seconds) |
| 49 |
17 |
now = System.os_time(:second) |
| 50 |
17 |
diff = abs(now - timestamp_unix) |
| 51 |
|
|
| 52 |
17 |
if diff <= tolerance do |
| 53 |
|
:ok |
| 54 |
|
else |
| 55 |
|
{:error, :stale_callback} |
| 56 |
|
end |
| 57 |
|
end |
| 58 |
|
|
| 59 |
|
@doc "Compute HMAC-SHA256 over the Stripe signed payload string." |
| 60 |
|
@spec compute_stripe_hmac(secret :: String.t(), timestamp :: integer(), body :: String.t()) :: String.t() |
| 61 |
|
def compute_stripe_hmac(secret, timestamp, body) do |
| 62 |
18 |
signed_payload = "#{timestamp}.#{body}" |
| 63 |
|
:crypto.mac(:hmac, :sha256, secret, signed_payload) |
| 64 |
18 |
|> Base.encode16(case: :lower) |
| 65 |
|
end |
| 66 |
|
|
| 67 |
|
@doc """ |
| 68 |
|
Derive a replay-safe deduplication key for a Stripe callback. |
| 69 |
|
Combines event_id (from parsed body) with timestamp for uniqueness. |
| 70 |
|
""" |
| 71 |
|
@spec stripe_message_id(event_id :: String.t(), timestamp :: integer()) :: String.t() |
| 72 |
|
def stripe_message_id(event_id, timestamp) do |
| 73 |
:-( |
"stripe:#{event_id}:#{timestamp}" |
| 74 |
|
end |
| 75 |
|
|
| 76 |
|
# Private helpers |
| 77 |
|
|
| 78 |
16 |
defp parse_stripe_signature(header) when is_binary(header) do |
| 79 |
16 |
parts = |
| 80 |
|
header |
| 81 |
|
|> String.split(",") |
| 82 |
30 |
|> Enum.map(&String.split(&1, "=", parts: 2)) |
| 83 |
|
|
| 84 |
16 |
timestamp = |
| 85 |
|
Enum.find_value(parts, fn |
| 86 |
13 |
["t", v] -> String.to_integer(v) |
| 87 |
3 |
_ -> nil |
| 88 |
|
end) |
| 89 |
|
|
| 90 |
16 |
signatures = |
| 91 |
|
parts |
| 92 |
30 |
|> Enum.filter(fn [k | _] -> k == "v1" end) |
| 93 |
14 |
|> Enum.map(fn [_, v] -> v end) |
| 94 |
|
|
| 95 |
16 |
cond do |
| 96 |
3 |
is_nil(timestamp) -> {:error, :malformed_signature} |
| 97 |
13 |
signatures == [] -> {:error, :malformed_signature} |
| 98 |
12 |
true -> {:ok, timestamp, signatures} |
| 99 |
|
end |
| 100 |
|
rescue |
| 101 |
:-( |
_ -> {:error, :malformed_signature} |
| 102 |
|
end |
| 103 |
|
|
| 104 |
:-( |
defp parse_stripe_signature(_), do: {:error, :malformed_signature} |
| 105 |
|
|
| 106 |
5 |
defp constant_time_equal?(a, b) when byte_size(a) != byte_size(b), do: false |
| 107 |
6 |
defp constant_time_equal?(a, b) do |
| 108 |
6 |
:crypto.hash_equals(a, b) |
| 109 |
|
rescue |
| 110 |
|
_ -> |
| 111 |
|
# Fallback for OTP versions without hash_equals |
| 112 |
|
Enum.zip(String.to_charlist(a), String.to_charlist(b)) |
| 113 |
:-( |
|> Enum.reduce(0, fn {x, y}, acc -> Bitwise.bor(acc, Bitwise.bxor(x, y)) end) |
| 114 |
:-( |
|> Kernel.==(0) |
| 115 |
|
end |
| 116 |
|
end |