@agentsbloom/sdk 0.4.0 → 0.5.0

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.
@@ -0,0 +1,874 @@
1
+ /**
2
+ * RFC 9421 HTTP Message Signature verification for incoming agent requests.
3
+ *
4
+ * This module replaces the inline, regex-driven verifier that lived in
5
+ * `index.js`. Extracting it is not cosmetic — the old code interleaved
6
+ * parsing, key resolution, canonicalization and crypto in one `try` block
7
+ * inside a 1,300-line request handler, which is why several of the defects
8
+ * below survived three hardening passes.
9
+ *
10
+ * What changed, and why each one mattered
11
+ * ---------------------------------------
12
+ *
13
+ * 1. **Real structured-field parsing.** `keyid` used to be pulled with
14
+ * `/keyid="([^"]+)"/` — the first match ANYWHERE in the header, including
15
+ * inside a quoted covered-component name. The key used to verify could
16
+ * therefore differ from the key named in the signed parameters. Component
17
+ * parameters (`;sf`, `;bs`, `;req`, `;key`) were dropped silently, so a
18
+ * signature over `"content-digest";sf` verified against the canonical
19
+ * value of plain `"content-digest"`.
20
+ *
21
+ * 2. **ECDSA actually works.** `crypto.verify(hash, base, key, sig)` defaults
22
+ * to DER-encoded ECDSA signatures. RFC 9421 (and WebCrypho, and JOSE)
23
+ * use raw `r||s`. Every genuine ES256/ES384/ES512 signature therefore
24
+ * failed verification, while `/ap2/capabilities` advertised support for
25
+ * all three. Now pinned with `dsaEncoding: 'ieee-p1363'`.
26
+ *
27
+ * 3. **Algorithm is pinned to the key, precisely.** The old check was a
28
+ * family prefix test (`rsa` ↔ `rs*`, `ec` ↔ `es*`), so `ES256` against a
29
+ * P-521 key passed the policy gate. Curves and RSA modulus size are now
30
+ * checked explicitly, and symmetric (`oct`) JWKs are refused outright — a
31
+ * published HMAC key is a forgeable key.
32
+ *
33
+ * 4. **Authority binding is required by default, and the authority is
34
+ * validated.** Without `@authority` in the covered components, a signature
35
+ * captured at store A is structurally valid at store B for the same path —
36
+ * a real cross-merchant replay whenever two stores trust the same JWKS.
37
+ *
38
+ * Covering `@authority` is necessary but NOT sufficient on its own: the
39
+ * canonical value comes from the request's own `Host` header, so an
40
+ * attacker replaying store A's signature at store B need only send
41
+ * `Host: store-a.com` for the base to reconstruct identically. Binding is
42
+ * therefore only real when the verifier also knows which authorities are
43
+ * legitimately its own — hence `expectedAuthorities`.
44
+ *
45
+ * 5. **Missing covered headers fail.** `req.headers[comp] || ''` meant a
46
+ * header the client signed but an intermediary stripped verified against
47
+ * the empty string.
48
+ *
49
+ * 6. **The digest covers the received bytes.** See `resolveBodyBytes`.
50
+ *
51
+ * 7. **Nonce records outlive the acceptance window.** The replay TTL used to
52
+ * be the signature's own `expires`, which the freshness rules allow to be
53
+ * up to `clockSkewMs` in the PAST — so a near-expired signature recorded
54
+ * its nonce for as little as one second and then replayed freely inside
55
+ * the remaining skew window.
56
+ *
57
+ * @see https://www.rfc-editor.org/rfc/rfc9421
58
+ */
59
+
60
+ import crypto from 'crypto';
61
+ import dns from 'dns';
62
+
63
+ import { parseDictionary, ByteSequence, StructuredFieldError } from './structured-fields.js';
64
+ import {
65
+ buildSignatureBase,
66
+ verifyContentDigest,
67
+ resolveBodyBytes,
68
+ normalizeAuthority,
69
+ PROFILE_STRICT,
70
+ PROFILE_LEGACY,
71
+ SignatureBaseError,
72
+ } from './signature-base.js';
73
+
74
+ /** Components every AgentsBloom signature must cover in strict mode. */
75
+ export const STRICT_REQUIRED_COMPONENTS = ['@method', '@authority', '@path'];
76
+ /** Components required by the legacy profile (no authority binding). */
77
+ export const LEGACY_REQUIRED_COMPONENTS = ['@method', '@path'];
78
+
79
+ const JWKS_CACHE_MAX_ENTRIES = 500;
80
+ const JWKS_POSITIVE_TTL_MS = 60 * 60 * 1000;
81
+ const JWKS_NEGATIVE_TTL_MS = 60 * 1000;
82
+ const JWKS_FETCH_TIMEOUT_MS = 5000;
83
+ const JWKS_MAX_BYTES = 256 * 1024;
84
+ const JWKS_MAX_KEYS = 100;
85
+
86
+ const MIN_RSA_MODULUS_BITS = 2048;
87
+ const NONCE_PATTERN = /^[\x21-\x7e]{16,256}$/;
88
+ /** Bounds the keyid before it is used in a cache key or a log line. */
89
+ const KEYID_PATTERN = /^[\x21-\x7e]{1,256}$/;
90
+
91
+ /**
92
+ * Signature algorithm registry.
93
+ *
94
+ * Keys are the lowercased `alg` parameter. Both the RFC 9421 HTTP Signature
95
+ * Algorithms registry names and the JOSE names are accepted, because the
96
+ * agent SDK and JWKS `alg` fields use JOSE spellings while the RFC registry
97
+ * is what a conformant third-party client will send.
98
+ *
99
+ * `rsa-v1_5-sha384` / `-sha512` are not registered names, but the previous
100
+ * implementation accepted them via prefix matching and the regression suite
101
+ * pins SHA-384, so they stay — explicitly, with the right hash.
102
+ */
103
+ const ALGORITHMS = Object.freeze({
104
+ // --- RFC 9421 registry ---
105
+ 'rsa-pss-sha512': { canonical: 'rsa-pss-sha512', kty: 'RSA', hash: 'sha512', rsaPadding: 'pss' },
106
+ 'rsa-v1_5-sha256': { canonical: 'rsa-v1_5-sha256', kty: 'RSA', hash: 'sha256', rsaPadding: 'pkcs1' },
107
+ 'rsa-v1_5-sha384': { canonical: 'rsa-v1_5-sha384', kty: 'RSA', hash: 'sha384', rsaPadding: 'pkcs1' },
108
+ 'rsa-v1_5-sha512': { canonical: 'rsa-v1_5-sha512', kty: 'RSA', hash: 'sha512', rsaPadding: 'pkcs1' },
109
+ 'ecdsa-p256-sha256': { canonical: 'ecdsa-p256-sha256', kty: 'EC', hash: 'sha256', curve: 'prime256v1', jwkCrv: 'P-256' },
110
+ 'ecdsa-p384-sha384': { canonical: 'ecdsa-p384-sha384', kty: 'EC', hash: 'sha384', curve: 'secp384r1', jwkCrv: 'P-384' },
111
+ 'ecdsa-p521-sha512': { canonical: 'ecdsa-p521-sha512', kty: 'EC', hash: 'sha512', curve: 'secp521r1', jwkCrv: 'P-521' },
112
+ ed25519: { canonical: 'ed25519', kty: 'OKP', hash: null, jwkCrv: 'Ed25519' },
113
+ // --- JOSE aliases, mapped onto the SAME canonical algorithm ---
114
+ //
115
+ // The `canonical` field exists because one algorithm has several registered
116
+ // spellings and a JWK may be pinned with a different one than the signature
117
+ // declares. Node's WebCrypto, for instance, exports an Ed25519 public JWK
118
+ // with `alg: "Ed25519"` while JOSE clients send `alg="EdDSA"`. Comparing the
119
+ // raw strings rejected a perfectly valid signature.
120
+ rs256: { canonical: 'rsa-v1_5-sha256', kty: 'RSA', hash: 'sha256', rsaPadding: 'pkcs1' },
121
+ rs384: { canonical: 'rsa-v1_5-sha384', kty: 'RSA', hash: 'sha384', rsaPadding: 'pkcs1' },
122
+ rs512: { canonical: 'rsa-v1_5-sha512', kty: 'RSA', hash: 'sha512', rsaPadding: 'pkcs1' },
123
+ ps256: { canonical: 'rsa-pss-sha256', kty: 'RSA', hash: 'sha256', rsaPadding: 'pss' },
124
+ ps384: { canonical: 'rsa-pss-sha384', kty: 'RSA', hash: 'sha384', rsaPadding: 'pss' },
125
+ ps512: { canonical: 'rsa-pss-sha512', kty: 'RSA', hash: 'sha512', rsaPadding: 'pss' },
126
+ es256: { canonical: 'ecdsa-p256-sha256', kty: 'EC', hash: 'sha256', curve: 'prime256v1', jwkCrv: 'P-256' },
127
+ es384: { canonical: 'ecdsa-p384-sha384', kty: 'EC', hash: 'sha384', curve: 'secp384r1', jwkCrv: 'P-384' },
128
+ es512: { canonical: 'ecdsa-p521-sha512', kty: 'EC', hash: 'sha512', curve: 'secp521r1', jwkCrv: 'P-521' },
129
+ eddsa: { canonical: 'ed25519', kty: 'OKP', hash: null, jwkCrv: 'Ed25519' },
130
+ });
131
+
132
+ /** Algorithms advertised in discovery documents; kept in sync with the table. */
133
+ export const SUPPORTED_SIGNATURE_ALGORITHMS = Object.freeze(Object.keys(ALGORITHMS));
134
+
135
+ /**
136
+ * A verification failure. `publicMessage` is what the caller may see;
137
+ * `detail` is for the server log only.
138
+ */
139
+ class VerificationFailure extends Error {
140
+ constructor({ status = 403, code = 'invalid_signature', publicMessage = 'Invalid HTTP Message Signature.', detail }) {
141
+ super(detail || publicMessage);
142
+ this.name = 'VerificationFailure';
143
+ this.status = status;
144
+ this.code = code;
145
+ this.publicMessage = publicMessage;
146
+ this.detail = detail || publicMessage;
147
+ }
148
+ }
149
+
150
+ function reject(detail, overrides = {}) {
151
+ throw new VerificationFailure({ detail, ...overrides });
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // SSRF protection for outbound JWKS fetches
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * True when a hostname or IP literal points somewhere a merchant's server
160
+ * must never be tricked into fetching: loopback, link-local (cloud metadata),
161
+ * RFC 1918, CGNAT, multicast, and IPv6 equivalents.
162
+ *
163
+ * @param {string} hostname
164
+ * @returns {boolean}
165
+ */
166
+ export function isBlockedSsrfHostname(hostname) {
167
+ const host = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
168
+ if (!host) return true;
169
+ if (host === 'localhost' || host.endsWith('.localhost') || host === '0.0.0.0') return true;
170
+ if (host === '::' || host === '::1') return true;
171
+ if (host.startsWith('127.')) return true;
172
+ if (host.startsWith('169.254.') || host.startsWith('fe80:')) return true;
173
+ if (host.startsWith('10.') || host.startsWith('192.168.')) return true;
174
+ // IPv6 unique-local (fc00::/7) and IPv4-mapped forms.
175
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
176
+ if (host.startsWith('::ffff:')) return isBlockedSsrfHostname(host.slice('::ffff:'.length));
177
+
178
+ const parts = host.split('.');
179
+ if (parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part))) {
180
+ const [a, b] = parts.map(Number);
181
+ if (parts.some((part) => Number(part) > 255)) return true;
182
+ if (a === 0 || a >= 224) return true; // "this network", multicast, reserved
183
+ if (a === 10) return true;
184
+ if (a === 127) return true;
185
+ if (a === 169 && b === 254) return true;
186
+ if (a === 172 && b >= 16 && b <= 31) return true;
187
+ if (a === 192 && b === 168) return true;
188
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
189
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
190
+ }
191
+ return false;
192
+ }
193
+
194
+ /**
195
+ * Resolves a hostname and refuses it when ANY resolved address is private.
196
+ *
197
+ * Blocking by hostname alone is trivially bypassed: an attacker-controlled
198
+ * (or merely misconfigured) DNS name can resolve to 169.254.169.254. This
199
+ * still has a TOCTOU window against a rebinding attacker, but it closes the
200
+ * single-lookup bypass, which is the realistic case for a config mistake.
201
+ *
202
+ * @param {string} hostname
203
+ */
204
+ async function assertPublicHostname(hostname) {
205
+ if (isBlockedSsrfHostname(hostname)) {
206
+ reject(`JWKS host "${hostname}" resolves to a private or reserved address`, { status: 503, code: 'jwks_blocked' });
207
+ }
208
+ // An IP literal needs no lookup; the check above already covered it.
209
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) return;
210
+ let addresses;
211
+ try {
212
+ addresses = await dns.promises.lookup(hostname, { all: true, verbatim: true });
213
+ } catch (err) {
214
+ reject(`JWKS host "${hostname}" could not be resolved: ${err.code || err.message}`, {
215
+ status: 503,
216
+ code: 'jwks_unreachable',
217
+ });
218
+ }
219
+ for (const { address } of addresses) {
220
+ if (isBlockedSsrfHostname(address)) {
221
+ reject(`JWKS host "${hostname}" resolves to the private address ${address}`, {
222
+ status: 503,
223
+ code: 'jwks_blocked',
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // JWKS cache
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * A bounded, swept JWKS cache. Positive entries hold a `KeyObject`; negative
235
+ * entries record "this JWKS does not contain that kid" so an attacker cannot
236
+ * turn unknown key ids into one outbound HTTPS fetch per request.
237
+ */
238
+ export function createJwksCache({ maxEntries = JWKS_CACHE_MAX_ENTRIES } = {}) {
239
+ const entries = new Map();
240
+
241
+ return {
242
+ get(cacheKey) {
243
+ const entry = entries.get(cacheKey);
244
+ if (!entry) return null;
245
+ if (entry.expires <= Date.now()) {
246
+ entries.delete(cacheKey);
247
+ return null;
248
+ }
249
+ return entry;
250
+ },
251
+ put(cacheKey, entry) {
252
+ if (entries.size >= maxEntries) {
253
+ const now = Date.now();
254
+ for (const [key, value] of entries) {
255
+ if (value.expires <= now) entries.delete(key);
256
+ }
257
+ if (entries.size >= maxEntries) {
258
+ const oldest = entries.keys().next().value;
259
+ if (oldest !== undefined) entries.delete(oldest);
260
+ }
261
+ }
262
+ entries.set(cacheKey, entry);
263
+ },
264
+ clear() {
265
+ entries.clear();
266
+ },
267
+ get size() {
268
+ return entries.size;
269
+ },
270
+ };
271
+ }
272
+
273
+ /** JWK members that indicate PRIVATE key material. */
274
+ const PRIVATE_JWK_MEMBERS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'k'];
275
+
276
+ /**
277
+ * Validates a JWK against the declared algorithm and turns it into a Node
278
+ * public `KeyObject`.
279
+ *
280
+ * @param {Record<string, unknown>} jwk
281
+ * @param {string} algName - lowercased alg parameter
282
+ * @returns {crypto.KeyObject}
283
+ */
284
+ function importVerificationKey(jwk, algName) {
285
+ const spec = ALGORITHMS[algName];
286
+ if (!spec) reject(`unsupported signature algorithm "${algName}"`);
287
+
288
+ if (!jwk || typeof jwk !== 'object') reject('JWKS entry is not an object');
289
+
290
+ // A symmetric key in a published JWKS is a public secret: anyone who can
291
+ // read the JWKS could forge signatures. Refuse rather than "support HMAC".
292
+ if (jwk.kty === 'oct') {
293
+ reject('JWKS contains a symmetric (oct) key; published symmetric keys are forgeable');
294
+ }
295
+ for (const member of PRIVATE_JWK_MEMBERS) {
296
+ if (jwk[member] !== undefined) {
297
+ reject(`JWKS entry contains private key material ("${member}")`);
298
+ }
299
+ }
300
+ if (jwk.kty !== spec.kty) {
301
+ reject(`JWKS key type "${jwk.kty}" does not match algorithm "${algName}" (expects ${spec.kty})`);
302
+ }
303
+ if (spec.jwkCrv && jwk.crv !== spec.jwkCrv) {
304
+ reject(`JWKS curve "${jwk.crv}" does not match algorithm "${algName}" (expects ${spec.jwkCrv})`);
305
+ }
306
+ if (jwk.use !== undefined && jwk.use !== 'sig') {
307
+ reject(`JWKS entry declares use "${jwk.use}"; only "sig" keys may verify signatures`);
308
+ }
309
+ if (jwk.key_ops !== undefined) {
310
+ if (!Array.isArray(jwk.key_ops) || !jwk.key_ops.includes('verify')) {
311
+ reject('JWKS entry key_ops does not permit "verify"');
312
+ }
313
+ }
314
+ if (typeof jwk.alg === 'string') {
315
+ // Compare CANONICAL algorithms, not raw spellings: `Ed25519` and `EdDSA`
316
+ // are the same algorithm, as are `RS256` and `rsa-v1_5-sha256`.
317
+ const pinned = ALGORITHMS[jwk.alg.toLowerCase()];
318
+ if (!pinned) {
319
+ reject(`JWKS entry is pinned to unrecognized alg "${jwk.alg}"`);
320
+ }
321
+ if (pinned.canonical !== spec.canonical) {
322
+ reject(`JWKS entry is pinned to alg "${jwk.alg}" but the signature declares "${algName}"`);
323
+ }
324
+ }
325
+
326
+ let key;
327
+ try {
328
+ key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
329
+ } catch (err) {
330
+ reject(`JWKS entry could not be imported: ${err.message}`);
331
+ }
332
+ if (key.type !== 'public') reject('JWKS entry did not import as a public key');
333
+
334
+ const details = key.asymmetricKeyDetails || {};
335
+ if (spec.kty === 'RSA') {
336
+ const bits = Number(details.modulusLength || 0);
337
+ if (!(bits >= MIN_RSA_MODULUS_BITS)) {
338
+ reject(`RSA key is ${bits || 'an unknown number of'} bits; the minimum is ${MIN_RSA_MODULUS_BITS}`);
339
+ }
340
+ }
341
+ if (spec.curve && details.namedCurve !== spec.curve) {
342
+ reject(`resolved EC key uses curve "${details.namedCurve}" but algorithm "${algName}" requires "${spec.curve}"`);
343
+ }
344
+ if (spec.kty === 'OKP' && key.asymmetricKeyType !== 'ed25519') {
345
+ reject(`resolved key type "${key.asymmetricKeyType}" is not ed25519`);
346
+ }
347
+ return key;
348
+ }
349
+
350
+ /**
351
+ * Validates a JWKS document shape and finds the entry for a key id.
352
+ *
353
+ * @param {unknown} jwks
354
+ * @param {string} keyid
355
+ * @returns {Record<string, unknown>|null}
356
+ */
357
+ function selectJwk(jwks, keyid) {
358
+ if (!jwks || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) {
359
+ reject('configured JWKS is invalid: expected an object with a "keys" array', {
360
+ status: 503,
361
+ code: 'jwks_invalid',
362
+ });
363
+ }
364
+ if (jwks.keys.length > JWKS_MAX_KEYS) {
365
+ reject(`configured JWKS declares ${jwks.keys.length} keys; the maximum is ${JWKS_MAX_KEYS}`, {
366
+ status: 503,
367
+ code: 'jwks_invalid',
368
+ });
369
+ }
370
+ const matches = jwks.keys.filter((candidate) => candidate && candidate.kid === keyid);
371
+ if (matches.length > 1) {
372
+ // Two keys with one kid means "which key verified this?" has no answer.
373
+ reject(`configured JWKS contains ${matches.length} keys with kid "${keyid}"`, {
374
+ status: 503,
375
+ code: 'jwks_ambiguous',
376
+ });
377
+ }
378
+ return matches[0] || null;
379
+ }
380
+
381
+ /**
382
+ * Fetches a JWKS over HTTPS with SSRF, redirect, timeout and size limits.
383
+ *
384
+ * @param {string} url
385
+ * @param {typeof fetch} fetchImpl
386
+ * @returns {Promise<unknown>}
387
+ */
388
+ async function fetchJwks(url, fetchImpl) {
389
+ let parsed;
390
+ try {
391
+ parsed = new URL(url);
392
+ } catch {
393
+ reject('configured agentJwksUrl is not a valid URL', { status: 503, code: 'jwks_misconfigured' });
394
+ }
395
+ if (parsed.protocol !== 'https:') {
396
+ reject('configured agentJwksUrl must use HTTPS', { status: 503, code: 'jwks_misconfigured' });
397
+ }
398
+ if (parsed.username || parsed.password) {
399
+ reject('configured agentJwksUrl must not embed credentials', { status: 503, code: 'jwks_misconfigured' });
400
+ }
401
+ await assertPublicHostname(parsed.hostname);
402
+
403
+ let response;
404
+ try {
405
+ response = await fetchImpl(parsed.href, {
406
+ redirect: 'error',
407
+ headers: { accept: 'application/json' },
408
+ signal: AbortSignal.timeout(JWKS_FETCH_TIMEOUT_MS),
409
+ });
410
+ } catch (err) {
411
+ reject(`JWKS fetch failed: ${err.message}`, { status: 503, code: 'jwks_unreachable' });
412
+ }
413
+ if (!response.ok) {
414
+ reject(`JWKS request failed with HTTP ${response.status}`, { status: 503, code: 'jwks_unreachable' });
415
+ }
416
+ const declared = Number(response.headers?.get?.('content-length') || 0);
417
+ if (declared > JWKS_MAX_BYTES) {
418
+ reject('JWKS response is too large', { status: 503, code: 'jwks_invalid' });
419
+ }
420
+ const text = await response.text();
421
+ if (text.length > JWKS_MAX_BYTES) {
422
+ reject('JWKS response is too large', { status: 503, code: 'jwks_invalid' });
423
+ }
424
+ try {
425
+ return JSON.parse(text);
426
+ } catch {
427
+ reject('JWKS response is not valid JSON', { status: 503, code: 'jwks_invalid' });
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Resolves the public key for a key id, with caching.
433
+ *
434
+ * @returns {Promise<crypto.KeyObject>}
435
+ */
436
+ async function resolveKey({ keyid, algName, jwks, jwksUrl, jwksCache, namespace, fetchImpl }) {
437
+ if (!jwks && !jwksUrl) {
438
+ reject('RFC 9421 verification is not configured', {
439
+ status: 503,
440
+ code: 'jwks_unconfigured',
441
+ publicMessage:
442
+ 'RFC 9421 verification is not configured for this store. '
443
+ + 'Set config.agentJwks or config.agentJwksUrl to your agent population\'s JWKS.',
444
+ });
445
+ }
446
+
447
+ // The cache key identifies the KEY SET BY CONTENT, not merely as "inline".
448
+ //
449
+ // This matters now that the replay namespace is derived from the store's
450
+ // baseUrl rather than a random per-instance UUID: two middleware instances
451
+ // for the same store but with DIFFERENT inline JWKS would otherwise share the
452
+ // cache entry, and instance B would happily verify against instance A's key.
453
+ // Fingerprinting the document keeps distinct key sets distinct while still
454
+ // letting identical configuration share cache across instances.
455
+ const source = jwks
456
+ ? `inline:${crypto.createHash('sha256').update(JSON.stringify(jwks.keys ?? jwks)).digest('base64url').slice(0, 22)}`
457
+ : `url:${jwksUrl}`;
458
+ // The algorithm is part of the cache key too: the same kid verified under a
459
+ // different alg is a different (rejected) policy decision, and caching only
460
+ // by kid would let one accepted alg poison another.
461
+ const cacheKey = `${namespace}\u0000${source}\u0000${keyid}\u0000${algName}`;
462
+
463
+ const cached = jwksCache.get(cacheKey);
464
+ if (cached?.key) return cached.key;
465
+ if (cached?.negative) reject(cached.detail || 'configured JWKS does not contain the requested key id');
466
+
467
+ const document = jwks || (await fetchJwks(jwksUrl, fetchImpl));
468
+ const jwk = selectJwk(document, keyid);
469
+ if (!jwk) {
470
+ const detail = 'configured JWKS does not contain the requested key id';
471
+ jwksCache.put(cacheKey, { negative: true, detail, expires: Date.now() + JWKS_NEGATIVE_TTL_MS });
472
+ reject(detail);
473
+ }
474
+
475
+ let key;
476
+ try {
477
+ key = importVerificationKey(jwk, algName);
478
+ } catch (err) {
479
+ // Cache the rejection: a policy mismatch is stable for this kid+alg, and
480
+ // re-deriving it on every request is free amplification.
481
+ const detail = err instanceof VerificationFailure ? err.detail : err.message;
482
+ jwksCache.put(cacheKey, { negative: true, detail, expires: Date.now() + JWKS_NEGATIVE_TTL_MS });
483
+ throw err;
484
+ }
485
+ jwksCache.put(cacheKey, { key, expires: Date.now() + JWKS_POSITIVE_TTL_MS });
486
+ return key;
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Signature verification
491
+ // ---------------------------------------------------------------------------
492
+
493
+ /**
494
+ * Verifies a signature over one candidate base.
495
+ *
496
+ * @param {string} algName
497
+ * @param {crypto.KeyObject} key
498
+ * @param {Buffer} signature
499
+ * @param {string} base
500
+ * @returns {boolean}
501
+ */
502
+ function verifyOverBase(algName, key, signature, base) {
503
+ const spec = ALGORITHMS[algName];
504
+ const data = Buffer.from(base, 'utf8');
505
+
506
+ if (spec.kty === 'OKP') {
507
+ // Ed25519 signs the message directly and is always 64 bytes.
508
+ if (signature.length !== 64) return false;
509
+ return crypto.verify(null, data, key, signature);
510
+ }
511
+
512
+ if (spec.kty === 'EC') {
513
+ // RFC 9421 / JOSE / WebCrypto all use raw r||s. Node defaults to DER,
514
+ // which is why every real ECDSA signature failed before this line.
515
+ const expectedLength = { prime256v1: 64, secp384r1: 96, secp521r1: 132 }[spec.curve];
516
+ if (signature.length !== expectedLength) return false;
517
+ return crypto.verify(spec.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
518
+ }
519
+
520
+ const keyInput = spec.rsaPadding === 'pss'
521
+ ? {
522
+ key,
523
+ padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
524
+ saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
525
+ }
526
+ : { key, padding: crypto.constants.RSA_PKCS1_PADDING };
527
+ return crypto.verify(spec.hash, data, keyInput, signature);
528
+ }
529
+
530
+ /**
531
+ * Extracts and validates the signature parameters from a parsed
532
+ * `Signature-Input` dictionary member.
533
+ */
534
+ function readSignatureParams(member) {
535
+ const params = member.params;
536
+ const created = params.get('created');
537
+ const expires = params.get('expires');
538
+ const nonce = params.get('nonce');
539
+ const alg = params.get('alg');
540
+ const keyid = params.get('keyid');
541
+
542
+ if (!Number.isSafeInteger(created)) reject('signature parameter "created" must be an integer');
543
+ if (!Number.isSafeInteger(expires)) reject('signature parameter "expires" must be an integer');
544
+ if (typeof nonce !== 'string') reject('signature parameter "nonce" must be a string');
545
+ if (typeof alg !== 'string') reject('signature parameter "alg" must be a string');
546
+ if (typeof keyid !== 'string') reject('signature parameter "keyid" must be a string');
547
+
548
+ for (const name of params.keys()) {
549
+ if (!['created', 'expires', 'nonce', 'alg', 'keyid', 'tag'].includes(name)) {
550
+ reject(`unknown signature parameter "${name}"`);
551
+ }
552
+ }
553
+ return { created, expires, nonce, alg, keyid };
554
+ }
555
+
556
+ /**
557
+ * Verifies an RFC 9421 signed request.
558
+ *
559
+ * @param {object} options
560
+ * @param {object} options.req - the Express request (for body bytes)
561
+ * @param {import('./signature-base.js').SignatureRequestContext} options.requestContext
562
+ * @param {string} options.signatureHeader
563
+ * @param {string} options.signatureInputHeader
564
+ * @param {object|null} options.jwks
565
+ * @param {string|null} options.jwksUrl
566
+ * @param {ReturnType<typeof createJwksCache>} options.jwksCache
567
+ * @param {{ claim: (key: string, expiresAtMs: number) => boolean|Promise<boolean> }} options.nonceCache
568
+ * @param {string} options.nonceNamespace
569
+ * @param {number} options.maxAgeMs
570
+ * @param {number} options.clockSkewMs
571
+ * @param {boolean} [options.requireAuthority=true]
572
+ * @param {Set<string>|null} [options.expectedAuthorities] - normalized
573
+ * authorities this deployment legitimately answers for. When provided, a
574
+ * request whose Host is not one of them is refused, which is what makes
575
+ * `@authority` an actual cross-host replay defence rather than a
576
+ * self-consistent echo of a spoofable header.
577
+ * @param {boolean} [options.acceptLegacyProfile=false]
578
+ * @param {boolean} [options.allowReserializedBody=false]
579
+ * @param {typeof fetch} [options.fetchImpl]
580
+ * @param {number} [options.nowMs]
581
+ * @returns {Promise<{ ok: true, keyid: string, identity: string, profile: string }
582
+ * | { ok: false, status: number, code: string, publicMessage: string, detail: string }>}
583
+ */
584
+ export async function verifyHttpMessageSignature(options) {
585
+ const {
586
+ req,
587
+ requestContext,
588
+ signatureHeader,
589
+ signatureInputHeader,
590
+ jwks = null,
591
+ jwksUrl = null,
592
+ jwksCache,
593
+ nonceCache,
594
+ nonceNamespace,
595
+ maxAgeMs,
596
+ clockSkewMs,
597
+ requireAuthority = true,
598
+ expectedAuthorities = null,
599
+ acceptLegacyProfile = false,
600
+ allowReserializedBody = false,
601
+ fetchImpl = fetch,
602
+ nowMs = Date.now(),
603
+ } = options;
604
+
605
+ try {
606
+ // --- 0. Is a trust root configured at all? -----------------------------
607
+ // A pure configuration check with no cost, hoisted to the front so the
608
+ // merchant sees "configure your JWKS" rather than some downstream symptom
609
+ // of the same missing configuration.
610
+ if (!jwks && !jwksUrl) {
611
+ reject('RFC 9421 verification is not configured', {
612
+ status: 503,
613
+ code: 'jwks_unconfigured',
614
+ publicMessage:
615
+ 'RFC 9421 verification is not configured: set config.agentJwks or config.agentJwksUrl '
616
+ + 'to your agent population\'s JWKS.',
617
+ });
618
+ }
619
+
620
+ // --- 1. Parse both headers as structured fields -------------------------
621
+ let inputDict;
622
+ let signatureDict;
623
+ try {
624
+ inputDict = parseDictionary(signatureInputHeader);
625
+ signatureDict = parseDictionary(signatureHeader);
626
+ } catch (err) {
627
+ if (err instanceof StructuredFieldError) {
628
+ reject(`Signature/Signature-Input is not a valid structured field: ${err.message}`);
629
+ }
630
+ throw err;
631
+ }
632
+
633
+ // Structural and protocol-capability errors below carry an ACTIONABLE
634
+ // public message: they describe this store's published protocol surface,
635
+ // not its secrets, and a legitimate agent needs them to correct itself.
636
+ // Key-resolution and crypto failures stay generic (see the defaults on
637
+ // `reject`), because those would form a verification-state oracle.
638
+ if (inputDict.size !== 1) {
639
+ reject(`Signature-Input carries ${inputDict.size} labels; exactly one is supported`, {
640
+ code: 'multi_label',
641
+ publicMessage: 'Multi-label HTTP Message Signatures are not supported; send exactly one signature label.',
642
+ });
643
+ }
644
+ if (signatureDict.size !== 1) {
645
+ reject(`Signature carries ${signatureDict.size} labels; exactly one is supported`, {
646
+ code: 'multi_label',
647
+ publicMessage: 'Multi-label HTTP Message Signatures are not supported; send exactly one signature label.',
648
+ });
649
+ }
650
+ const [label, inputMember] = [...inputDict.entries()][0];
651
+ const [signatureLabel, signatureMember] = [...signatureDict.entries()][0];
652
+ if (label !== signatureLabel) {
653
+ reject(`Signature label "${signatureLabel}" does not match Signature-Input label "${label}"`, {
654
+ code: 'label_mismatch',
655
+ publicMessage: 'Signature and Signature-Input labels do not match.',
656
+ });
657
+ }
658
+ if (!inputMember.isInnerList) {
659
+ reject('Signature-Input value must be an inner list of covered components', {
660
+ code: 'malformed_signature_input',
661
+ publicMessage: 'Signature-Input must be a structured-field inner list of covered components.',
662
+ });
663
+ }
664
+ if (!(signatureMember.value instanceof ByteSequence)) {
665
+ reject('Signature value must be a byte sequence', {
666
+ code: 'malformed_signature',
667
+ publicMessage: 'Signature must be a structured-field byte sequence (sig1=:base64:).',
668
+ });
669
+ }
670
+ const signatureBytes = signatureMember.value.bytes;
671
+ if (signatureBytes.length === 0 || signatureBytes.length > 1024) {
672
+ reject(`signature is ${signatureBytes.length} bytes, which is outside the accepted range`);
673
+ }
674
+
675
+ // --- 2. Covered components --------------------------------------------
676
+ const components = inputMember.items.map((item) => {
677
+ if (typeof item.value !== 'string') {
678
+ reject('every covered component identifier must be a string');
679
+ }
680
+ return { name: item.value, params: item.params };
681
+ });
682
+ const componentNames = new Set(components.map((component) => component.name));
683
+
684
+ // --- 3. Signature parameters ------------------------------------------
685
+ const { created, expires, nonce, alg, keyid } = readSignatureParams(inputMember);
686
+ const algName = alg.toLowerCase();
687
+ if (!ALGORITHMS[algName]) {
688
+ reject(`unsupported signature algorithm "${alg}"`, {
689
+ code: 'alg_unsupported',
690
+ // The accepted algorithm set is already published in
691
+ // /ap2/capabilities and /openapi.json, so naming it is not disclosure.
692
+ publicMessage: `Unsupported signature algorithm. This store accepts: ${SUPPORTED_SIGNATURE_ALGORITHMS.join(', ')}.`,
693
+ });
694
+ }
695
+ if (!KEYID_PATTERN.test(keyid)) {
696
+ reject('keyid must be 1-256 printable ASCII characters', { code: 'keyid_invalid' });
697
+ }
698
+ if (/^https?:\/\//i.test(keyid)) {
699
+ reject('remote keyid URLs are not accepted; configure a trusted JWKS and use its exact key id', {
700
+ code: 'keyid_remote',
701
+ publicMessage: 'Remote keyid URLs are not accepted; use a key id from this store\'s configured JWKS.',
702
+ });
703
+ }
704
+ if (!NONCE_PATTERN.test(nonce)) {
705
+ reject('nonce must be 16-256 printable ASCII characters', {
706
+ code: 'nonce_invalid',
707
+ publicMessage: 'The signature nonce must be 16-256 printable ASCII characters.',
708
+ });
709
+ }
710
+
711
+ // --- 4. Freshness ------------------------------------------------------
712
+ // Freshness failures are disclosed: an agent with a skewed clock or a
713
+ // stale signature must be able to tell that apart from a bad key, and the
714
+ // window is a published policy rather than a secret.
715
+ const createdMs = created * 1000;
716
+ const expiresMs = expires * 1000;
717
+ const staleness = { code: 'signature_stale', publicMessage: 'HTTP Message Signature is expired or outside the allowed lifetime.' };
718
+ if (expires <= created) reject('"expires" must be after "created"', staleness);
719
+ if (createdMs > nowMs + clockSkewMs) reject('signature was created in the future', staleness);
720
+ if (createdMs < nowMs - maxAgeMs) reject('signature is older than the accepted window', staleness);
721
+ if (expiresMs < nowMs - clockSkewMs) reject('signature has expired', staleness);
722
+ if (expiresMs - createdMs > maxAgeMs) reject('signature lifetime exceeds the accepted maximum', staleness);
723
+
724
+ // --- 5. Body digest ----------------------------------------------------
725
+ // Any request with a body MUST cover content-digest; without it the
726
+ // signature says nothing about the payload being acted on.
727
+ let bodyBytes;
728
+ try {
729
+ bodyBytes = resolveBodyBytes(req, { allowReserializedBody });
730
+ } catch (err) {
731
+ reject(err.message, {
732
+ status: 503,
733
+ code: 'raw_body_unavailable',
734
+ publicMessage: 'This store cannot verify signed request bodies. Contact the store operator.',
735
+ });
736
+ }
737
+ if (bodyBytes.length > 0 && !componentNames.has('content-digest')) {
738
+ reject('signatures over a request with a body must cover content-digest', {
739
+ code: 'digest_not_covered',
740
+ publicMessage: 'Signatures over a request with a body must cover content-digest.',
741
+ });
742
+ }
743
+ if (componentNames.has('content-digest')) {
744
+ const digestResult = verifyContentDigest(requestContext.header('content-digest'), bodyBytes);
745
+ // Disclosed: the caller computed this digest itself, so telling it the
746
+ // digest did not match the received body reveals nothing it did not
747
+ // already know, and it is the single most useful debugging signal.
748
+ if (!digestResult.ok) {
749
+ reject(digestResult.reason, {
750
+ code: 'digest_mismatch',
751
+ publicMessage: 'The content-digest header does not match the received request body.',
752
+ });
753
+ }
754
+ }
755
+
756
+ // --- 6. Required components -------------------------------------------
757
+ // Strict mode binds the authority, which is what prevents a signature
758
+ // captured at store A from being replayed against store B.
759
+ const strictRequired = requireAuthority
760
+ ? STRICT_REQUIRED_COMPONENTS
761
+ : STRICT_REQUIRED_COMPONENTS.filter((name) => name !== '@authority');
762
+ const strictSatisfied = strictRequired.every((name) => componentNames.has(name));
763
+ const legacySatisfied = LEGACY_REQUIRED_COMPONENTS.every((name) => componentNames.has(name));
764
+
765
+ if (!strictSatisfied) {
766
+ if (!acceptLegacyProfile || !legacySatisfied) {
767
+ const missing = strictRequired.filter((name) => !componentNames.has(name));
768
+ reject(`signature must cover ${missing.join(', ')}`, {
769
+ publicMessage: `HTTP Message Signatures for this store must cover ${strictRequired.join(', ')} and content-digest.`,
770
+ });
771
+ }
772
+ }
773
+
774
+ // Covering @authority only binds the host if we also know which hosts are
775
+ // ours. Otherwise an attacker replaying a signature captured at store A
776
+ // simply sends `Host: store-a.com`, and the base reconstructs identically.
777
+ if (expectedAuthorities instanceof Set && expectedAuthorities.size > 0) {
778
+ const presented = normalizeAuthority(requestContext.authority, requestContext.scheme);
779
+ if (!expectedAuthorities.has(presented)) {
780
+ reject(
781
+ `request authority "${presented || '(absent)'}" is not one this deployment answers for`,
782
+ {
783
+ code: 'authority_rejected',
784
+ publicMessage: 'The request Host header is not served by this store.',
785
+ },
786
+ );
787
+ }
788
+ }
789
+
790
+ // --- 7. Key resolution -------------------------------------------------
791
+ const key = await resolveKey({
792
+ keyid,
793
+ algName,
794
+ jwks,
795
+ jwksUrl,
796
+ jwksCache,
797
+ namespace: nonceNamespace,
798
+ fetchImpl,
799
+ });
800
+
801
+ // --- 8. Canonicalize and verify ---------------------------------------
802
+ // Strict first. The legacy profile is only attempted when the merchant
803
+ // opted in, and only after the conformant base failed, so enabling it
804
+ // never weakens a signature that already verifies strictly.
805
+ const profilesToTry = [PROFILE_STRICT];
806
+ if (acceptLegacyProfile) profilesToTry.push(PROFILE_LEGACY);
807
+
808
+ let matchedProfile = null;
809
+ let lastBaseError = null;
810
+ for (const profile of profilesToTry) {
811
+ if (profile === PROFILE_STRICT && !strictSatisfied) continue;
812
+ if (profile === PROFILE_LEGACY && !legacySatisfied) continue;
813
+ let base;
814
+ try {
815
+ base = buildSignatureBase({
816
+ components,
817
+ signatureParamsRaw: inputMember.raw,
818
+ request: requestContext,
819
+ profile,
820
+ });
821
+ } catch (err) {
822
+ if (err instanceof SignatureBaseError) {
823
+ lastBaseError = err;
824
+ continue;
825
+ }
826
+ throw err;
827
+ }
828
+ if (verifyOverBase(algName, key, signatureBytes, base)) {
829
+ matchedProfile = profile;
830
+ break;
831
+ }
832
+ }
833
+
834
+ if (!matchedProfile) {
835
+ if (lastBaseError) reject(`signature base could not be built: ${lastBaseError.message}`);
836
+ reject(`signature did not verify for keyid "${keyid}" (alg ${algName})`);
837
+ }
838
+
839
+ // --- 9. Single-use nonce ----------------------------------------------
840
+ // The record must survive at least as long as this signature could still
841
+ // be accepted, otherwise a near-expired signature records a nonce for a
842
+ // second and then replays inside the remaining skew window.
843
+ const replayKey = `${nonceNamespace}:rfc:${keyid}:${nonce}`;
844
+ const retainUntilMs = Math.max(expiresMs, nowMs + maxAgeMs + clockSkewMs);
845
+ const claimed = await nonceCache.claim(replayKey, retainUntilMs);
846
+ if (!claimed) {
847
+ reject(`nonce "${nonce}" has already been used for keyid "${keyid}"`, {
848
+ code: 'nonce_reused',
849
+ publicMessage: 'HTTP Message Signature nonce has already been used.',
850
+ });
851
+ }
852
+
853
+ return { ok: true, keyid, identity: `rfc:${keyid}`, profile: matchedProfile };
854
+ } catch (err) {
855
+ if (err instanceof VerificationFailure) {
856
+ return {
857
+ ok: false,
858
+ status: err.status,
859
+ code: err.code,
860
+ publicMessage: err.publicMessage,
861
+ detail: err.detail,
862
+ };
863
+ }
864
+ // Never let an unexpected internal error be interpreted as success, and
865
+ // never leak its text to the caller.
866
+ return {
867
+ ok: false,
868
+ status: 403,
869
+ code: 'invalid_signature',
870
+ publicMessage: 'Invalid HTTP Message Signature.',
871
+ detail: `unexpected verification error: ${err?.message || err}`,
872
+ };
873
+ }
874
+ }