cover/Elixir.WalletIntegrations.CallbackVerifier.html

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
Line Hits Source