@pithy-sh/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capability/bindings.d.ts +5 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +44 -3
- package/dist/capability/capability.d.ts +33 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/validateBindings.js +1 -1
- package/dist/controlPlane/http/cors.js +1 -1
- package/dist/createBackend.d.ts +2 -1
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +5 -4
- package/dist/error/http.d.ts +27 -5
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/http.js +33 -9
- package/dist/error/messageParams.d.ts +28 -0
- package/dist/error/messageParams.d.ts.map +1 -0
- package/dist/error/messageParams.js +31 -0
- package/dist/error/terminal.d.ts +16 -5
- package/dist/error/terminal.d.ts.map +1 -1
- package/dist/error/terminal.js +16 -5
- package/dist/http/oidcWebhook.d.ts +283 -0
- package/dist/http/oidcWebhook.d.ts.map +1 -0
- package/dist/http/oidcWebhook.js +414 -0
- package/dist/http/signedWebhook.d.ts +9 -65
- package/dist/http/signedWebhook.d.ts.map +1 -1
- package/dist/http/signedWebhook.js +19 -9
- package/dist/http/webhookWindow.d.ts +33 -0
- package/dist/http/webhookWindow.d.ts.map +1 -0
- package/dist/http/webhookWindow.js +35 -0
- package/dist/logger/errorHandler.d.ts +11 -0
- package/dist/logger/errorHandler.d.ts.map +1 -0
- package/dist/logger/errorHandler.js +87 -0
- package/dist/naming/environment.js +1 -1
- package/dist/naming/provisionScope.d.ts +108 -3
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +27 -2
- package/dist/naming/resource.js +1 -1
- package/dist/naming/resourceNames.js +1 -1
- package/dist/schema/describedness.d.ts +30 -1
- package/dist/schema/describedness.d.ts.map +1 -1
- package/dist/schema/describedness.js +42 -6
- package/dist/test-utils/oidcFixtures.d.ts +93 -0
- package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
- package/dist/test-utils/oidcFixtures.js +111 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/naming.js +1 -1
- package/dist/workflow/stepMessage.d.ts +28 -3
- package/dist/workflow/stepMessage.d.ts.map +1 -1
- package/dist/workflow/stepMessage.js +48 -6
- package/package.json +1 -1
- package/src/capability/bindings.ts +49 -0
- package/src/capability/capability.ts +34 -0
- package/src/createBackend.ts +7 -3
- package/src/error/http.ts +34 -10
- package/src/error/messageParams.ts +38 -0
- package/src/error/terminal.ts +16 -5
- package/src/http/oidcWebhook.ts +672 -0
- package/src/http/signedWebhook.ts +44 -16
- package/src/http/webhookWindow.ts +35 -0
- package/src/logger/errorHandler.ts +94 -0
- package/src/naming/provisionScope.ts +150 -4
- package/src/schema/describedness.ts +51 -6
- package/src/test-utils/oidcFixtures.ts +138 -0
- package/src/version.generated.ts +1 -1
- package/src/workflow/stepMessage.ts +52 -6
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/pithyError.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/http/oidcWebhook.ts
|
|
6
|
+
/**
|
|
7
|
+
* The `signed-webhook` strategy for a sender that proves itself with an **OIDC token** rather than an HMAC.
|
|
8
|
+
* Issuers, key endpoint, audience and a claims predicate in; the verified claims, or a `PithyError`, out.
|
|
9
|
+
*
|
|
10
|
+
* Two mechanisms, one declared strategy. {@link ./verification.VerificationStrategy} already says as much —
|
|
11
|
+
* *"an HMAC, a signed JWS chain, an OIDC token… a rail whose sender proves it differently implements its
|
|
12
|
+
* own"* — so this sits beside {@link ./signedWebhook.requireSignedWebhook} and wears its seams, rather than
|
|
13
|
+
* adding a word to that union. What changes is where the proof comes from: with an HMAC both ends hold a
|
|
14
|
+
* secret, and with OIDC neither end holds anything. The sender asks its identity provider for a token that
|
|
15
|
+
* expires in minutes; the receiver asks the same provider for the public keys that check it.
|
|
16
|
+
*
|
|
17
|
+
* ## The claims check is the boundary, not the signature
|
|
18
|
+
*
|
|
19
|
+
* An identity provider signs tokens for **everybody** with the same handful of keys. GitHub signs one for
|
|
20
|
+
* every repository on the planet; Google signs one for every Pub/Sub push subscription. So a valid signature
|
|
21
|
+
* proves only *that provider minted this*, which any of their users can arrange in under a minute. What makes
|
|
22
|
+
* a token **ours** is what it claims: `aud`, which the sender names when it mints the token and which is the
|
|
23
|
+
* endpoint it was minted for, and whatever the {@link OidcWebhookScheme.claims} predicate asserts on top —
|
|
24
|
+
* `sub === "repo:pithy-sh/pithy:environment:npm-publish"` for a release job, an issuer's service account for
|
|
25
|
+
* a push subscription. Verify the signature, skip those, and the result is an endpoint every user of that
|
|
26
|
+
* provider can post to.
|
|
27
|
+
*
|
|
28
|
+
* That is why `claims` is a **predicate the route holds** rather than a value in configuration. It is the
|
|
29
|
+
* same constraint as a `ValueRotator`: the declaration is the `signed-webhook` tag on the route, and the code
|
|
30
|
+
* that decides who is allowed lives with the route, where it can be read beside the handler it protects.
|
|
31
|
+
*
|
|
32
|
+
* ## Four steps, in this order, and the order is the security property
|
|
33
|
+
*
|
|
34
|
+
* 1. **Pin the algorithm before touching a key.** `alg` arrives in a header nobody has verified yet, so it is
|
|
35
|
+
* parsed against a literal `RS256` rather than looked up. That is what makes the two published confusions
|
|
36
|
+
* unreachable — `none`, which asks for the signature to be skipped, and `HS256`, which asks the verifier to
|
|
37
|
+
* HMAC with the *public* key as if it were a shared secret. Neither survives a literal.
|
|
38
|
+
* 2. **Resolve the key by `kid` from the issuer's published set.** Never from the token, which is why there is
|
|
39
|
+
* no `jwk`/`x5u` handling here: a token that carries its own key is a token that verifies itself.
|
|
40
|
+
* 3. **Verify the signature over the exact received segments** — `header.claims` as encoded, never a
|
|
41
|
+
* re-serialization. Key order and whitespace belong to the sender, not to us.
|
|
42
|
+
* 4. **Then read the claims,** and only then. A claim from an unverified token is a claim an attacker wrote,
|
|
43
|
+
* so a verifier that reads `iss` or `sub` to decide *which key to fetch* has already trusted the forgery it
|
|
44
|
+
* is about to check. Issuer, audience, expiry, and the caller's predicate are all after the signature.
|
|
45
|
+
*
|
|
46
|
+
* ## What a refusal says
|
|
47
|
+
*
|
|
48
|
+
* `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
|
|
49
|
+
* bad signature, wrong audience, expired, predicate false. The step goes in `detail`, which the HTTP codec
|
|
50
|
+
* strips, so an operator reading a log learns which check refused it and the sender learns only that
|
|
51
|
+
* something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
|
|
52
|
+
* audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
|
|
53
|
+
* {@link snippet}, and never the signature and never the whole token.
|
|
54
|
+
*
|
|
55
|
+
* Two failures are deliberately **not** the sender's. An endpoint with no issuers, no audience, no key
|
|
56
|
+
* endpoint, or a clock that is not a clock is a configuration fault and takes `core/internal` — reporting it
|
|
57
|
+
* as an unverified webhook sends an operator hunting a forger who is not there. And a key endpoint that
|
|
58
|
+
* cannot be reached or answers nonsense is `core/upstream_failed` (502), because a 500 tells an operator to
|
|
59
|
+
* read *our* logs and the thing that broke is the issuer's. Both still refuse the delivery: fail closed is
|
|
60
|
+
* about what happens to the request, not about whose code goes on it.
|
|
61
|
+
*
|
|
62
|
+
* ## What this proves, and what it does not
|
|
63
|
+
*
|
|
64
|
+
* The same caveat as the HMAC scheme, for the same reason: this proves a delivery is authentic and fresh,
|
|
65
|
+
* never that it is *new*. A captured token replays until it expires. A handler that grants, charges, or
|
|
66
|
+
* deletes needs its own uniqueness key.
|
|
67
|
+
*/
|
|
68
|
+
/** How long a fetched key set is reused. Issuers rotate slowly, and an unknown `kid` forces a refresh anyway. */
|
|
69
|
+
const OIDC_JWKS_TTL_SECONDS = 3600;
|
|
70
|
+
/** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
|
|
71
|
+
const OIDC_CLOCK_SKEW_SECONDS = 60;
|
|
72
|
+
/**
|
|
73
|
+
* The most tolerance this verifier accepts, and it refuses above rather than clamping.
|
|
74
|
+
*
|
|
75
|
+
* A skew is a replay window in the plainest possible units: every second of it is a second longer a captured
|
|
76
|
+
* token keeps working. Five minutes is the far end of real drift between two machines that both run NTP; past
|
|
77
|
+
* that a number has stopped covering drift and started covering a capture, which is what the sentence above
|
|
78
|
+
* says about an hour. Refused rather than clamped because a `604800` is a typo or a misunderstanding either
|
|
79
|
+
* way — clamped, it boots and nobody reads the line again; refused, it is a red boot and a two-character fix.
|
|
80
|
+
*/
|
|
81
|
+
const OIDC_MAX_CLOCK_SKEW_SECONDS = 300;
|
|
82
|
+
/** The header a bearer token arrives in, unless a sender names another. Lower case, as Hono presents one. */
|
|
83
|
+
const DEFAULT_TOKEN_HEADER = "authorization";
|
|
84
|
+
/** RSASSA-PKCS1-v1_5 with SHA-256 — the one algorithm `alg: RS256` names, fixed here rather than derived. */
|
|
85
|
+
const RS256 = {
|
|
86
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
87
|
+
hash: "SHA-256"
|
|
88
|
+
};
|
|
89
|
+
/** The unpadded url alphabet (RFC 4648 §5). Whitespace, `+`, `/` and `=` are all outside it, deliberately. */
|
|
90
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
91
|
+
/** How much of a token-supplied value a refusal may quote. Enough to diagnose, too little to fill a log. */
|
|
92
|
+
const SNIPPET_LENGTH = 120;
|
|
93
|
+
/**
|
|
94
|
+
* The header of an OIDC token, as tightly as it can be stated. `alg` is a literal for the reason in the
|
|
95
|
+
* module doc; `kid` is required because a key is resolved by it and a token that names no key cannot be
|
|
96
|
+
* checked against one. `.loose()` because a provider may add a field and a real token must not be refused
|
|
97
|
+
* for carrying something new.
|
|
98
|
+
*/
|
|
99
|
+
const OidcTokenHeader = z.object({
|
|
100
|
+
alg: z.literal("RS256").describe("The signing algorithm. A literal, not a lookup: `alg` arrives in an unverified header, and accepting `none` or `HS256` from it is how JWS verifiers are bypassed."),
|
|
101
|
+
kid: z.string().min(1).describe("Which of the issuer's published keys signed the token. Resolved against the issuer's key set.")
|
|
102
|
+
}).loose().describe("The header of an OIDC token, constrained to the one shape this verifier accepts.");
|
|
103
|
+
/**
|
|
104
|
+
* One published RSA verification key, in the shape a JWKS endpoint returns. `.loose()` because providers add
|
|
105
|
+
* fields, and a key set must not be rejected for carrying something this build does not read.
|
|
106
|
+
*/
|
|
107
|
+
const OidcJwk = z.object({
|
|
108
|
+
kid: z.string().min(1).describe("The key id a token header names."),
|
|
109
|
+
kty: z.string().min(1).describe("The key type. Only `RSA` is accepted — an OIDC token is signed with one."),
|
|
110
|
+
n: z.string().min(1).describe("The RSA modulus, base64url."),
|
|
111
|
+
e: z.string().min(1).describe("The RSA public exponent, base64url."),
|
|
112
|
+
alg: z.string().min(1).optional().describe("The algorithm the key is published for, when stated."),
|
|
113
|
+
use: z.string().min(1).optional().describe("What the key is published for — `sig` for a signing key.")
|
|
114
|
+
}).loose().describe("One published OIDC verification key, as a JWKS endpoint returns it.");
|
|
115
|
+
/** A published key set. At least one key: an empty set is an endpoint that is not answering properly. */
|
|
116
|
+
const OidcJwks = z.object({ keys: z.array(OidcJwk).min(1).describe("Every key the issuer currently publishes for signature verification.") }).loose().describe("The response of an issuer's JWKS endpoint.");
|
|
117
|
+
/**
|
|
118
|
+
* The claims an OIDC token carries, narrowed to the ones that decide anything. `.loose()` keeps the rest —
|
|
119
|
+
* the predicate reads them, and a claim set must not be refused for being richer than expected.
|
|
120
|
+
*
|
|
121
|
+
* `aud` is a **string and only a string**, though RFC 7519 permits an array. The audience check is the
|
|
122
|
+
* boundary, and an array turns an equality into a set membership — one more shape for a verifier to be wrong
|
|
123
|
+
* about, on the check the whole scheme rests on. No sender the kit talks to mints one.
|
|
124
|
+
*/
|
|
125
|
+
const OidcClaims = z.object({
|
|
126
|
+
iss: z.string().min(1).describe("Who issued the token. Checked against the configured issuers, and nothing else."),
|
|
127
|
+
aud: z.string().min(1).describe("Who the token was minted for — the endpoint the sender named when it asked for the token. The claim that makes a token ours rather than merely the issuer's."),
|
|
128
|
+
sub: z.string().min(1).optional().describe("Which identity the issuer minted the token for — a workflow's `repo:owner/name:environment:x`, a service account. Read by the caller's `claims` predicate, never by this module."),
|
|
129
|
+
exp: z.number().int().describe("When the token expires, in seconds since the epoch."),
|
|
130
|
+
nbf: z.number().int().optional().describe("The earliest the token may be used, in seconds since the epoch."),
|
|
131
|
+
iat: z.number().int().optional().describe("When the token was issued, in seconds since the epoch.")
|
|
132
|
+
}).loose().describe("The claims on an OIDC token, narrowed to the ones verification depends on.");
|
|
133
|
+
/** The default transport: the runtime's own `fetch`. */
|
|
134
|
+
const oidcJwksFetch = (url) => fetch(url);
|
|
135
|
+
/**
|
|
136
|
+
* A key cache in memory, expiring on a clock the caller owns.
|
|
137
|
+
*
|
|
138
|
+
* One object per call, so two callers never share one by accident and a test's cache dies with its test. The
|
|
139
|
+
* clock is injected for the same reason it is everywhere else here: an expiry that depends on wall time is an
|
|
140
|
+
* assertion nobody can write.
|
|
141
|
+
*/
|
|
142
|
+
function memoryJwksCache(options = {}) {
|
|
143
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
144
|
+
const held = /* @__PURE__ */ new Map();
|
|
145
|
+
return {
|
|
146
|
+
get: async (url) => {
|
|
147
|
+
const entry = held.get(url);
|
|
148
|
+
if (entry === void 0) return void 0;
|
|
149
|
+
if (entry.expiresAt <= now().getTime()) {
|
|
150
|
+
held.delete(url);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
return entry.keys;
|
|
154
|
+
},
|
|
155
|
+
set: async (url, keys, ttlSeconds) => {
|
|
156
|
+
held.set(url, {
|
|
157
|
+
keys,
|
|
158
|
+
expiresAt: now().getTime() + ttlSeconds * 1e3
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Verify one OIDC token and return its claims. Throws, never reports.
|
|
165
|
+
*
|
|
166
|
+
* The steps run in the order the module doc gives, and that order **is** the security property: algorithm
|
|
167
|
+
* pinned, key resolved by `kid` from the published set, signature checked over the received bytes, and only
|
|
168
|
+
* then a single claim read. A verifier that reads a claim to decide which key to fetch has trusted the thing
|
|
169
|
+
* it has not checked yet.
|
|
170
|
+
*
|
|
171
|
+
* @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
|
|
172
|
+
* unknown key, bad signature, wrong issuer or audience, expired, not yet valid, predicate false.
|
|
173
|
+
* @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
|
|
174
|
+
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read.
|
|
175
|
+
*/
|
|
176
|
+
async function verifyOidcToken(token, options) {
|
|
177
|
+
const issuers = options.issuers.filter((issuer) => issuer.length > 0);
|
|
178
|
+
if (issuers.length === 0 || options.audience.length === 0 || options.jwksUrl.length === 0) throw misconfigured("Give the OIDC webhook guard an issuer, an audience, and a JWKS URL.", "The OIDC webhook guard resolved no issuer, no audience, or no key endpoint, so it can verify nothing.");
|
|
179
|
+
const seconds = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
180
|
+
if (!Number.isFinite(seconds)) throw misconfigured("Give the OIDC webhook guard a valid clock.", "The OIDC webhook guard was handed a clock that is not a valid Date, so no token's freshness can be judged.");
|
|
181
|
+
const skew = options.skewSeconds ?? 60;
|
|
182
|
+
if (!Number.isFinite(skew) || skew < 0 || skew > 300) throw misconfigured(`Give the OIDC webhook guard a clock skew between 0 and 300 seconds.`, `The OIDC webhook guard was given a clock skew of ${String(skew)} seconds, and a tolerance must be a number from 0 to 300.`);
|
|
183
|
+
const jwksTtlSeconds = options.jwksTtlSeconds ?? 3600;
|
|
184
|
+
if (!Number.isFinite(jwksTtlSeconds) || jwksTtlSeconds < 0) throw misconfigured("Give the OIDC webhook guard a key-set lifetime of zero seconds or more.", `The OIDC webhook guard was given a key-set lifetime of ${String(jwksTtlSeconds)} seconds, which would never expire.`);
|
|
185
|
+
const { head, body, mac } = splitCompactJws(token);
|
|
186
|
+
const header = OidcTokenHeader.safeParse(decodeJwtJson(head, "the token header"));
|
|
187
|
+
if (!header.success) throw unverified(`the token header is not one this endpoint accepts — ${issues(header.error)}.`);
|
|
188
|
+
const key = await importKey(await resolveKey(header.data.kid, options));
|
|
189
|
+
if (!await crypto.subtle.verify(RS256.name, key, new Uint8Array(decodeBase64Url(mac, "the token signature")), new TextEncoder().encode(`${head}.${body}`))) throw unverified(`the token signature does not match the signed segments under key ${snippet(header.data.kid)}.`);
|
|
190
|
+
const parsed = OidcClaims.safeParse(decodeJwtJson(body, "the token claims"));
|
|
191
|
+
if (!parsed.success) throw unverified(`the token claims are not those of an OIDC token — ${issues(parsed.error)}.`);
|
|
192
|
+
const claims = parsed.data;
|
|
193
|
+
if (!issuers.includes(claims.iss)) throw unverified(`the token issuer is ${snippet(claims.iss)}, which this endpoint does not accept.`);
|
|
194
|
+
if (claims.aud !== options.audience) throw unverified(`the token audience is ${snippet(claims.aud)}, and this endpoint expects ${snippet(options.audience)}.`);
|
|
195
|
+
if (claims.exp + skew < seconds) throw unverified(`the token expired at ${instant(claims.exp)}.`);
|
|
196
|
+
if (claims.nbf !== void 0 && claims.nbf - skew > seconds) throw unverified(`the token is not valid before ${instant(claims.nbf)}.`);
|
|
197
|
+
if (claims.iat !== void 0 && claims.iat - skew > seconds) throw unverified(`the token was issued at ${instant(claims.iat)}, which is in the future.`);
|
|
198
|
+
if (!await options.claims(claims)) throw unverified(`the token is the issuer's but its claims are not accepted by this endpoint — subject ${snippet(claims.sub ?? "none")}.`);
|
|
199
|
+
return claims;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The OIDC gate, as a route wears it: `app.post(path, requireOidcWebhook({…}), zValidator("json", Body,
|
|
203
|
+
* validationHook), handler)`.
|
|
204
|
+
*
|
|
205
|
+
* ## Why the header is read before anything else
|
|
206
|
+
*
|
|
207
|
+
* The bearer header is the cheapest thing to check and the commonest thing to be missing, so it goes first. A
|
|
208
|
+
* delivery carrying no token is refused whatever the body holds, and fetching a key set before looking would
|
|
209
|
+
* be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
|
|
210
|
+
* callers.
|
|
211
|
+
*
|
|
212
|
+
* ## Why a cache is not optional here
|
|
213
|
+
*
|
|
214
|
+
* A caller that presents *something* is past that first check, and the next thing the verifier needs is the
|
|
215
|
+
* issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
|
|
216
|
+
* middleware is constructed, never per request, which is a cache that has never held anything. A `kid` is
|
|
217
|
+
* published by definition, which makes a 49-byte token naming one the cheapest thing on the internet to send,
|
|
218
|
+
* and with no cache each one is a round trip to the issuer, 1:1. That is a bandwidth multiplier aimed at
|
|
219
|
+
* somebody else's endpoint, and once the issuer rate-limits us {@link fetchJwks} maps it to
|
|
220
|
+
* `core/upstream_failed` for the genuine deliveries too — an anonymous caller taking the boundary down by
|
|
221
|
+
* asking it politely. A route wanting a harder bound (shared across isolates, or rate-limiting) passes its
|
|
222
|
+
* own {@link OidcWebhookScheme.jwksCache}, and this one is never built.
|
|
223
|
+
*
|
|
224
|
+
* A `kid` **nobody** publishes still costs one fetch per delivery, cache or no cache, because an unknown key
|
|
225
|
+
* id is how rotation announces itself and declining to look is an outage. That bound is per delivery and
|
|
226
|
+
* lives in {@link resolveKey}; the cache is what collapses the other flood, the one naming a key that exists.
|
|
227
|
+
*
|
|
228
|
+
* ## Why nothing is published to the request
|
|
229
|
+
*
|
|
230
|
+
* The gate proves the caller and returns; it sets no request variable. The claims a route cares about are the
|
|
231
|
+
* ones its own {@link OidcWebhookScheme.claims} predicate already asserted, and a handler that needs the
|
|
232
|
+
* whole claim set calls {@link verifyOidcToken} directly rather than reading a variable it must remember to
|
|
233
|
+
* null-check.
|
|
234
|
+
*
|
|
235
|
+
* The body is never read here — unlike the HMAC gate, whose proof covers the bytes. An OIDC token proves who
|
|
236
|
+
* sent the request and says nothing about what they sent, so the route's `zValidator` is the only thing that
|
|
237
|
+
* touches the body, and it sees an untouched stream.
|
|
238
|
+
*/
|
|
239
|
+
function requireOidcWebhook(options) {
|
|
240
|
+
const { header = DEFAULT_TOKEN_HEADER, now, ...scheme } = options;
|
|
241
|
+
const jwksCache = scheme.jwksCache ?? memoryJwksCache({ now });
|
|
242
|
+
return async (c, next) => {
|
|
243
|
+
const presented = c.req.header(header);
|
|
244
|
+
const token = presented === void 0 ? void 0 : bearerToken(presented);
|
|
245
|
+
if (token === void 0) throw unverified(`the delivery carries no ${header} header with a Bearer token.`);
|
|
246
|
+
await verifyOidcToken(token, {
|
|
247
|
+
...scheme,
|
|
248
|
+
jwksCache,
|
|
249
|
+
now: now?.() ?? /* @__PURE__ */ new Date()
|
|
250
|
+
});
|
|
251
|
+
await next();
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The key a token's `kid` names, from the cache when it holds it and from the issuer otherwise.
|
|
256
|
+
*
|
|
257
|
+
* A `kid` the cache does not hold triggers **exactly one** refresh, which is what makes key rotation
|
|
258
|
+
* invisible: a new signing key appears in a token before anything tells us to look for it, and waiting out a
|
|
259
|
+
* TTL would be an outage. Exactly one, because otherwise a stream of forged tokens naming random `kid`s is a
|
|
260
|
+
* way to make us hammer the issuer — one delivery buys one fetch, whatever it claims. A caller that wants a
|
|
261
|
+
* harder bound than that gives a {@link JwksCache} that rate-limits, which is the other thing injecting the
|
|
262
|
+
* store buys.
|
|
263
|
+
*/
|
|
264
|
+
async function resolveKey(kid, options) {
|
|
265
|
+
const cached = (await options.jwksCache?.get(options.jwksUrl))?.find((key) => key.kid === kid);
|
|
266
|
+
if (cached) return cached;
|
|
267
|
+
const refreshed = await fetchJwks(options);
|
|
268
|
+
await options.jwksCache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? 3600);
|
|
269
|
+
const found = refreshed.find((key) => key.kid === kid);
|
|
270
|
+
if (!found) throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Fetch and validate the issuer's key set.
|
|
275
|
+
*
|
|
276
|
+
* Every failure is `core/upstream_failed`, and every failure still refuses the delivery. The code says whose
|
|
277
|
+
* system to look at — a 500 would send an operator into our logs for an outage at an endpoint we do not run,
|
|
278
|
+
* and a 401 would send them after a forger. The URL is named because it is our own configuration, not
|
|
279
|
+
* anything a caller supplied.
|
|
280
|
+
*/
|
|
281
|
+
async function fetchJwks(options) {
|
|
282
|
+
const transport = options.transport ?? oidcJwksFetch;
|
|
283
|
+
let response;
|
|
284
|
+
try {
|
|
285
|
+
response = await transport(options.jwksUrl);
|
|
286
|
+
} catch (cause) {
|
|
287
|
+
throw upstream(`The OIDC key endpoint ${options.jwksUrl} did not answer.`, cause);
|
|
288
|
+
}
|
|
289
|
+
if (!response.ok) throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered ${response.status}.`);
|
|
290
|
+
let body;
|
|
291
|
+
try {
|
|
292
|
+
body = JSON.parse(await response.text());
|
|
293
|
+
} catch (cause) {
|
|
294
|
+
throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered a non-JSON body.`, cause);
|
|
295
|
+
}
|
|
296
|
+
const parsed = OidcJwks.safeParse(body);
|
|
297
|
+
if (!parsed.success) throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered an unexpected shape — ${issues(parsed.error)}.`);
|
|
298
|
+
return parsed.data.keys;
|
|
299
|
+
}
|
|
300
|
+
/** Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto. */
|
|
301
|
+
async function importKey(jwk) {
|
|
302
|
+
if (jwk.kty !== "RSA") throw upstream(`Published key ${snippet(jwk.kid)} is ${snippet(jwk.kty)}, and only RSA keys sign an OIDC token.`);
|
|
303
|
+
try {
|
|
304
|
+
return await crypto.subtle.importKey("jwk", {
|
|
305
|
+
...jwk,
|
|
306
|
+
alg: "RS256"
|
|
307
|
+
}, RS256, false, ["verify"]);
|
|
308
|
+
} catch (cause) {
|
|
309
|
+
throw upstream(`Published key ${snippet(jwk.kid)} could not be imported.`, cause);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Split a compact JWS, refusing anything that is not three non-empty segments.
|
|
314
|
+
*
|
|
315
|
+
* The empty-segment check is the structural half of the algorithm pin: `alg: none` produces a header, a
|
|
316
|
+
* payload, and nothing after the final dot, so it is refused here before a header is even decoded.
|
|
317
|
+
*/
|
|
318
|
+
function splitCompactJws(token) {
|
|
319
|
+
const segments = token.split(".");
|
|
320
|
+
if (segments.length !== 3) throw unverified(`the token has ${segments.length} segments, expected 3.`);
|
|
321
|
+
const [head, body, mac] = segments;
|
|
322
|
+
if (head.length === 0 || body.length === 0 || mac.length === 0) throw unverified("a token segment is empty; an unsigned token is not accepted.");
|
|
323
|
+
return {
|
|
324
|
+
head,
|
|
325
|
+
body,
|
|
326
|
+
mac
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Bytes from one unpadded base64url segment (RFC 4648 §5), strictly.
|
|
331
|
+
*
|
|
332
|
+
* A token segment is attacker-supplied by definition and `atob` is famously forgiving — it digests whitespace
|
|
333
|
+
* and, on some runtimes, characters that mean nothing. Silently decoding malformed input produces bytes that
|
|
334
|
+
* parse into *something*, and something is exactly what a forger wants a verifier to work with. Padding is
|
|
335
|
+
* refused too: RFC 7515 §2 mandates unpadded segments, and one token with two spellings is one more thing a
|
|
336
|
+
* comparison can be wrong about.
|
|
337
|
+
*/
|
|
338
|
+
function decodeBase64Url(value, label) {
|
|
339
|
+
if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) throw unverified(`${label} is not unpadded base64url.`);
|
|
340
|
+
const standard = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
341
|
+
const padded = standard.padEnd(standard.length + (4 - standard.length % 4) % 4, "=");
|
|
342
|
+
let binary;
|
|
343
|
+
try {
|
|
344
|
+
binary = atob(padded);
|
|
345
|
+
} catch (cause) {
|
|
346
|
+
throw unverified(`${label} failed base64url decode.`, cause);
|
|
347
|
+
}
|
|
348
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
349
|
+
}
|
|
350
|
+
/** One base64url JSON segment. `unknown` out — decoded bytes are not yet a known shape. */
|
|
351
|
+
function decodeJwtJson(segment, label) {
|
|
352
|
+
const text = new TextDecoder().decode(decodeBase64Url(segment, label));
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(text);
|
|
355
|
+
} catch (cause) {
|
|
356
|
+
throw unverified(`${label} is not JSON.`, cause);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** Read `Bearer <token>` from a header value, or `undefined` when it is not one. The scheme is case-insensitive. */
|
|
360
|
+
function bearerToken(value) {
|
|
361
|
+
return /^bearer +(\S+)$/i.exec(value.trim())?.[1];
|
|
362
|
+
}
|
|
363
|
+
/** The sender's refusal. One code for every step; the step is in `detail`, which never reaches the sender. */
|
|
364
|
+
function unverified(detail, cause) {
|
|
365
|
+
return new WebhookUnverifiedError({
|
|
366
|
+
action: "Send a current OIDC token minted for this endpoint's audience.",
|
|
367
|
+
detail: `The OIDC webhook guard refused a delivery: ${detail}`
|
|
368
|
+
}, cause === void 0 ? void 0 : { cause });
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Ours, not the sender's. Every value this refuses on is one *we* wrote — an issuer list, an audience, a
|
|
372
|
+
* clock, a tolerance — so it takes `core/internal` and an `action` naming the knob to turn, rather than
|
|
373
|
+
* sending an operator after a forger who is not there. Still a refusal: the delivery is denied either way.
|
|
374
|
+
*/
|
|
375
|
+
function misconfigured(action, detail) {
|
|
376
|
+
return new InternalError({
|
|
377
|
+
message: "This webhook endpoint is not configured.",
|
|
378
|
+
action,
|
|
379
|
+
detail
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** The issuer's failure, not the sender's and not ours. Still a refusal — 502 denies the delivery. */
|
|
383
|
+
function upstream(detail, cause) {
|
|
384
|
+
return new UpstreamError({
|
|
385
|
+
action: "Check that this Worker can reach the issuer's JWKS endpoint.",
|
|
386
|
+
detail
|
|
387
|
+
}, cause === void 0 ? void 0 : { cause });
|
|
388
|
+
}
|
|
389
|
+
/** Zod issues as `path:code` pairs — never `message` or `received`, which would echo the token. */
|
|
390
|
+
function issues(error) {
|
|
391
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ");
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* One epoch-seconds claim, rendered for a refusal.
|
|
395
|
+
*
|
|
396
|
+
* The comparisons above are arithmetic and are right for any number `z.number().int()` admits — but
|
|
397
|
+
* `toISOString()` is not: `new Date(-1e15 * 1000)` is an invalid Date and **throws a `RangeError`**, which is
|
|
398
|
+
* a plain `Error` escaping a module whose every other exit is a `PithyError`. It is thrown while composing
|
|
399
|
+
* the *refusal message* for a token that has already been decided against, so the token is still refused —
|
|
400
|
+
* but through {@link requireOidcWebhook} the sender chooses whether they get a 401 or a 500, and a 500 sends
|
|
401
|
+
* an operator into our logs over somebody else's forgery. So a claim outside `Date`'s range is quoted as the
|
|
402
|
+
* number it is, and the refusal stays the refusal it already was.
|
|
403
|
+
*/
|
|
404
|
+
function instant(epochSeconds) {
|
|
405
|
+
const at = /* @__PURE__ */ new Date(epochSeconds * 1e3);
|
|
406
|
+
return Number.isFinite(at.getTime()) ? at.toISOString() : `epoch second ${epochSeconds}`;
|
|
407
|
+
}
|
|
408
|
+
/** A token-supplied value, quoted and bounded, so a refusal names what failed without becoming the log. */
|
|
409
|
+
function snippet(value) {
|
|
410
|
+
const trimmed = value.length > SNIPPET_LENGTH ? `${value.slice(0, SNIPPET_LENGTH)}…` : value;
|
|
411
|
+
return JSON.stringify(trimmed);
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
export { OIDC_CLOCK_SKEW_SECONDS, OIDC_JWKS_TTL_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OidcClaims, OidcJwk, OidcJwks, memoryJwksCache, oidcJwksFetch, requireOidcWebhook, verifyOidcToken };
|
|
@@ -2,68 +2,6 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import type { MiddlewareHandler } from "hono";
|
|
4
4
|
import type { PithyHonoEnv } from "../capability/capability";
|
|
5
|
-
/**
|
|
6
|
-
* The `signed-webhook` strategy, for any sender. Secret, header name, tolerance and the exact received
|
|
7
|
-
* bytes in; verified, or a `PithyError`, out.
|
|
8
|
-
*
|
|
9
|
-
* `signed-webhook` has been a first-class strategy in {@link ./verification.VerificationStrategy} since the
|
|
10
|
-
* contract landed, but the only implementation was `@pithy-sh/payments`' webhook guard — welded to a rail
|
|
11
|
-
* catalog, a D1 table and a dedup insert, because it also records deliveries. So an adopter declaring the
|
|
12
|
-
* strategy on their own route had the word and nothing to put behind it. This is the word's implementation,
|
|
13
|
-
* and `@pithy-sh/payments`' Stripe rail is now one of its callers rather than a second copy of it.
|
|
14
|
-
*
|
|
15
|
-
* **Dedup and persistence stay the caller's**: at-least-once delivery is a property of the sender, not of the
|
|
16
|
-
* signature, and a verifier that also owned a table could not be composed by anyone whose table differs. Say
|
|
17
|
-
* the consequence plainly, because it is a security property and not only an availability one: this proves a
|
|
18
|
-
* delivery is authentic and fresh, never that it is *new*. Inside the tolerance a captured delivery replays
|
|
19
|
-
* as many times as it is sent. A handler that grants, charges, or deletes needs its own uniqueness key —
|
|
20
|
-
* payments spends a `UNIQUE (rail, providerEventId)` insert on exactly this.
|
|
21
|
-
*
|
|
22
|
-
* ## The scheme
|
|
23
|
-
*
|
|
24
|
-
* One header carries both halves of the proof: `<header>: t=1785931200,v1=5257a8…` — the timestamp the
|
|
25
|
-
* delivery claims, then one hex HMAC-SHA256 per signature, each computed over `<timestamp>.<body>`. Stripe's
|
|
26
|
-
* format, deliberately, because it is the one every other sender copied and because it gets the four things
|
|
27
|
-
* below right. The key names are options — a sender writing `v0` costs one line — but the signed payload is
|
|
28
|
-
* fixed. A caller-supplied template would be the one part of the scheme that must be byte-identical at both
|
|
29
|
-
* ends, and getting it wrong yields a verifier that verifies nothing while still returning.
|
|
30
|
-
*
|
|
31
|
-
* ## Why the timestamp is inside the signed payload
|
|
32
|
-
*
|
|
33
|
-
* A signature with no freshness window is valid forever: whoever captures one delivery can replay it for as
|
|
34
|
-
* long as the secret lives. The window only closes that if it cannot be stepped around — and it cannot,
|
|
35
|
-
* because the timestamp is part of what was signed. Re-dating a captured delivery to escape the window
|
|
36
|
-
* invalidates its own signature. A timestamp in a second header, or alongside the signature but outside it,
|
|
37
|
-
* would be a suggestion.
|
|
38
|
-
*
|
|
39
|
-
* The window is checked in **both** directions. A delivery dated far in the future is a clock problem or a
|
|
40
|
-
* crafted one, and neither is a delivery to act on; accepting it would also hand a forger an unbounded replay
|
|
41
|
-
* window the moment a secret leaks.
|
|
42
|
-
*
|
|
43
|
-
* ## Why the comparison is `crypto.subtle.verify`
|
|
44
|
-
*
|
|
45
|
-
* Comparing HMACs with `===` leaks how many leading bytes matched, and that leak is enough to forge a
|
|
46
|
-
* signature one byte at a time given enough attempts. WebCrypto's `verify` does the comparison itself, in
|
|
47
|
-
* constant time, in the platform — so the requirement is met by not hand-writing the compare at all.
|
|
48
|
-
* (`timingSafeEqual` in `../controlPlane/token/digest` is the string equivalent, for values WebCrypto will
|
|
49
|
-
* not compare for you.) Every listed signature is tried, because a sender rotating its secret signs with each
|
|
50
|
-
* active one, and refusing a delivery whose second signature matched would drop every delivery for the length
|
|
51
|
-
* of the rotation.
|
|
52
|
-
*
|
|
53
|
-
* ## What a refusal says
|
|
54
|
-
*
|
|
55
|
-
* `core/webhook_unverified`, 401, one code for every failing step. The step goes in `detail`, which the HTTP
|
|
56
|
-
* codec strips — so an operator reading a log learns which check refused it and the sender learns only that
|
|
57
|
-
* something did. **Never the secret, never a signature, never the body.** A refusal is the one message a
|
|
58
|
-
* hostile caller is guaranteed to receive, so it is the last place to echo anything it supplied or anything
|
|
59
|
-
* it is trying to guess.
|
|
60
|
-
*
|
|
61
|
-
* A caller that must word its own refusal — a rail whose own code is part of its contract — calls
|
|
62
|
-
* {@link checkSignedWebhook}, which reports rather than throws. {@link verifySignedWebhook} is that plus the
|
|
63
|
-
* kit's error.
|
|
64
|
-
*/
|
|
65
|
-
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
66
|
-
export declare const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
67
5
|
/**
|
|
68
6
|
* How many candidate signatures one delivery may spend, after the unusable ones are dropped.
|
|
69
7
|
*
|
|
@@ -85,7 +23,12 @@ export interface SignedWebhookScheme {
|
|
|
85
23
|
timestampKey?: string;
|
|
86
24
|
/** The header key holding a signature. Defaults to `v1`. */
|
|
87
25
|
signatureKey?: string;
|
|
88
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The freshness window, in seconds, either side of now. Defaults to {@link SIGNED_WEBHOOK_TOLERANCE_SECONDS}
|
|
28
|
+
* and must be a finite number from 0 to {@link SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}; anything else is
|
|
29
|
+
* `core/internal`, because this is the second operand of the freshness comparison and one `NaN` does not
|
|
30
|
+
* widen the window, it removes it.
|
|
31
|
+
*/
|
|
89
32
|
toleranceSeconds?: number;
|
|
90
33
|
}
|
|
91
34
|
/**
|
|
@@ -182,8 +125,9 @@ export type SignedWebhookRefusal =
|
|
|
182
125
|
* each caller keeps its vocabulary, rather than the whole verifier existing twice with two sets of comments
|
|
183
126
|
* to keep in step. Most callers want {@link verifySignedWebhook}.
|
|
184
127
|
*
|
|
185
|
-
* It still throws for **our** failure: an endpoint holding no usable secret,
|
|
186
|
-
* clock, is a configuration fault rather than a refusal, and every
|
|
128
|
+
* It still throws for **our** failure: an endpoint holding no usable secret, given a tolerance that is not a
|
|
129
|
+
* window, or handed a clock that is not a clock, is a configuration fault rather than a refusal, and every
|
|
130
|
+
* caller reports that the same way. Those three are checked first, before anything the sender wrote.
|
|
187
131
|
*
|
|
188
132
|
* `body` must be the **exact received bytes**. A parsed-and-re-serialized object is different bytes — key
|
|
189
133
|
* order alone changes it — and would never verify.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signedWebhook.d.ts","sourceRoot":"","sources":["../../src/http/signedWebhook.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"signedWebhook.d.ts","sourceRoot":"","sources":["../../src/http/signedWebhook.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAoF7D;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,IAAI,CAAC;AAE/C,wEAAwE;AACxE,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;AAE7D,0FAA0F;AAC1F,MAAM,WAAW,0BAA2B,SAAQ,mBAAmB;IACrE,2EAA2E;IAC3E,MAAM,EAAE,mBAAmB,CAAC;IAC5B,gHAAgH;IAChH,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,0EAA0E;AAC1E,MAAM,WAAW,yBAA0B,SAAQ,mBAAmB;IACpE;;;;;OAKG;IACH,MAAM,EAAE,mBAAmB,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACrH,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,kFAAkF;AAClF,MAAM,WAAW,mBAAmB;IAClC,4GAA4G;IAC5G,SAAS,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,GACnB,mBAAmB,GAAG,SAAS,CAuBjC;AAED;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB;AAC9B,kFAAkF;AAChF;IAAE,MAAM,EAAE,YAAY,CAAA;CAAE;AAC1B,2EAA2E;GACzE;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AACtD;;;;;;;GAOG;GACD;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAmF3C;AAkCD;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CAGf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,yBAAyB,GAAG,iBAAiB,CAAC,YAAY,CAAC,CAyBxG"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { InternalError, WebhookUnverifiedError } from "../error/pithyError.js";
|
|
4
|
+
import { SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS } from "./webhookWindow.js";
|
|
4
5
|
//#region src/http/signedWebhook.ts
|
|
5
6
|
/**
|
|
6
7
|
* The `signed-webhook` strategy, for any sender. Secret, header name, tolerance and the exact received
|
|
@@ -40,6 +41,11 @@ import { InternalError, WebhookUnverifiedError } from "../error/pithyError.js";
|
|
|
40
41
|
* crafted one, and neither is a delivery to act on; accepting it would also hand a forger an unbounded replay
|
|
41
42
|
* window the moment a secret leaks.
|
|
42
43
|
*
|
|
44
|
+
* The window's own width is checked before any delivery is. `skew > tolerance` is false when `tolerance` is
|
|
45
|
+
* `NaN`, so a non-finite tolerance does not widen the window — it deletes it, and every capture verifies
|
|
46
|
+
* forever. A tolerance outside `0 … {@link SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}` is therefore refused as the
|
|
47
|
+
* configuration fault it is, never honored and never clamped.
|
|
48
|
+
*
|
|
43
49
|
* ## Why the comparison is `crypto.subtle.verify`
|
|
44
50
|
*
|
|
45
51
|
* Comparing HMACs with `===` leaks how many leading bytes matched, and that leak is enough to forge a
|
|
@@ -62,8 +68,6 @@ import { InternalError, WebhookUnverifiedError } from "../error/pithyError.js";
|
|
|
62
68
|
* {@link checkSignedWebhook}, which reports rather than throws. {@link verifySignedWebhook} is that plus the
|
|
63
69
|
* kit's error.
|
|
64
70
|
*/
|
|
65
|
-
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
66
|
-
const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
67
71
|
/** The header key naming the timestamp, unless a sender names it otherwise. */
|
|
68
72
|
const DEFAULT_TIMESTAMP_KEY = "t";
|
|
69
73
|
/** The header key naming a signature, unless a sender names it otherwise. */
|
|
@@ -130,8 +134,9 @@ function parseSignedWebhookHeader(header, timestampKey, signatureKey) {
|
|
|
130
134
|
* each caller keeps its vocabulary, rather than the whole verifier existing twice with two sets of comments
|
|
131
135
|
* to keep in step. Most callers want {@link verifySignedWebhook}.
|
|
132
136
|
*
|
|
133
|
-
* It still throws for **our** failure: an endpoint holding no usable secret,
|
|
134
|
-
* clock, is a configuration fault rather than a refusal, and every
|
|
137
|
+
* It still throws for **our** failure: an endpoint holding no usable secret, given a tolerance that is not a
|
|
138
|
+
* window, or handed a clock that is not a clock, is a configuration fault rather than a refusal, and every
|
|
139
|
+
* caller reports that the same way. Those three are checked first, before anything the sender wrote.
|
|
135
140
|
*
|
|
136
141
|
* `body` must be the **exact received bytes**. A parsed-and-re-serialized object is different bytes — key
|
|
137
142
|
* order alone changes it — and would never verify.
|
|
@@ -143,17 +148,22 @@ async function checkSignedWebhook(body, header, options) {
|
|
|
143
148
|
action: `Give the ${options.header} guard a signing secret.`,
|
|
144
149
|
detail: `The signed-webhook guard on ${options.header} resolved no signing secret, so it can verify nothing.`
|
|
145
150
|
});
|
|
146
|
-
const timestampKey = options.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
|
|
147
|
-
const signatureKey = options.signatureKey ?? DEFAULT_SIGNATURE_KEY;
|
|
148
|
-
const parsed = header === null ? void 0 : parseSignedWebhookHeader(header, timestampKey, signatureKey);
|
|
149
|
-
if (parsed === void 0) return { reason: "unreadable" };
|
|
150
151
|
const tolerance = options.toleranceSeconds ?? 300;
|
|
152
|
+
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 3600) throw new InternalError({
|
|
153
|
+
message: "This webhook endpoint is not configured.",
|
|
154
|
+
action: `Give the ${options.header} guard a tolerance between 0 and ${SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS} seconds.`,
|
|
155
|
+
detail: `The signed-webhook guard on ${options.header} was given a tolerance of ${String(tolerance)} seconds, and a freshness window must be a number from 0 to ${SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}.`
|
|
156
|
+
});
|
|
151
157
|
const nowSeconds = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
152
158
|
if (!Number.isFinite(nowSeconds)) throw new InternalError({
|
|
153
159
|
message: "This webhook endpoint is not configured.",
|
|
154
160
|
action: `Give the ${options.header} guard a valid clock.`,
|
|
155
161
|
detail: `The signed-webhook guard on ${options.header} was handed a clock that is not a valid Date, so no delivery's freshness can be judged.`
|
|
156
162
|
});
|
|
163
|
+
const timestampKey = options.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
|
|
164
|
+
const signatureKey = options.signatureKey ?? DEFAULT_SIGNATURE_KEY;
|
|
165
|
+
const parsed = header === null ? void 0 : parseSignedWebhookHeader(header, timestampKey, signatureKey);
|
|
166
|
+
if (parsed === void 0) return { reason: "unreadable" };
|
|
157
167
|
const skew = Math.abs(nowSeconds - parsed.timestamp);
|
|
158
168
|
if (skew > tolerance) return {
|
|
159
169
|
reason: "stale",
|
|
@@ -246,4 +256,4 @@ function hexBytes(value) {
|
|
|
246
256
|
return bytes;
|
|
247
257
|
}
|
|
248
258
|
//#endregion
|
|
249
|
-
export { SIGNED_WEBHOOK_MAX_CANDIDATES,
|
|
259
|
+
export { SIGNED_WEBHOOK_MAX_CANDIDATES, checkSignedWebhook, parseSignedWebhookHeader, requireSignedWebhook, verifySignedWebhook };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The freshness window a signed webhook is checked against: the default, and the widest one accepted.
|
|
5
|
+
*
|
|
6
|
+
* **Two numbers in a module of their own, because of who has to read them.** They are the bounds a
|
|
7
|
+
* config schema states in its own `.describe()` — `packages/payments/src/config/config.ts` interpolates
|
|
8
|
+
* the maximum into the sentence an adopter reads — and a config schema is one half of a route contract,
|
|
9
|
+
* which a management client validates **in a browser**. `signedWebhook.ts` is a Hono middleware and
|
|
10
|
+
* reaches the whole Worker graph from `PithyHonoEnv` down; importing a number from it dragged `hono`,
|
|
11
|
+
* `kysely`, `kysely-d1` and `@cloudflare/workers-types` into `payments/src/http/schemas.ts`,
|
|
12
|
+
* `payments/src/http/responses.ts` and every module that reaches payments' config (#521).
|
|
13
|
+
*
|
|
14
|
+
* `tooling/browser-scopes` is the gate that caught it, and it is the gate that keeps this file a leaf:
|
|
15
|
+
* **nothing here may import anything.** A constant a browser-facing schema quotes belongs beside the
|
|
16
|
+
* other constants a browser may see, not inside the verifier that enforces it.
|
|
17
|
+
*/
|
|
18
|
+
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
19
|
+
export declare const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
20
|
+
/**
|
|
21
|
+
* The widest freshness window the verifier accepts, and it refuses above rather than clamping.
|
|
22
|
+
*
|
|
23
|
+
* A tolerance is a replay window in the plainest possible units: every second of it is a second longer a
|
|
24
|
+
* captured delivery keeps working. The five minutes above is Stripe's own and covers clock drift plus ordinary
|
|
25
|
+
* delivery latency; an hour is the far end of a sender that queues a delivery and re-sends it without re-dating
|
|
26
|
+
* it. Past that a number has stopped covering latency and started covering a capture.
|
|
27
|
+
*
|
|
28
|
+
* Refused rather than clamped because an `86400` is a typo or a misunderstanding either way — clamped, the
|
|
29
|
+
* endpoint keeps working and nobody reads the line again; refused, the first delivery says which knob is wrong
|
|
30
|
+
* and it is a two-character fix.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS = 3600;
|
|
33
|
+
//# sourceMappingURL=webhookWindow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhookWindow.d.ts","sourceRoot":"","sources":["../../src/http/webhookWindow.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,6GAA6G;AAC7G,eAAO,MAAM,gCAAgC,MAAM,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oCAAoC,OAAO,CAAC"}
|