@agentsbloom/sdk 0.2.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.
- package/LICENSE +21 -21
- package/README.md +184 -254
- package/SECURITY.md +24 -19
- package/index.d.ts +355 -124
- package/index.js +1582 -342
- package/lib/ap2.js +1017 -422
- package/lib/http-signatures.js +874 -0
- package/lib/money.js +283 -0
- package/lib/outcomes.js +108 -0
- package/lib/protocol.d.ts +229 -0
- package/lib/protocol.js +85 -0
- package/lib/shared-store.js +298 -0
- package/lib/signature-base.js +436 -0
- package/lib/structured-fields.js +398 -0
- package/package.json +95 -81
- package/telemetry.js +77 -50
- package/assets/logo-mark.svg +0 -25
|
@@ -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
|
+
}
|