@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.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * RFC 9421 signature-base construction — the canonical, conformant version.
3
+ *
4
+ * This is the single source of truth for "what bytes did the agent sign".
5
+ * Every AgentsBloom verifier (this SDK, `@agentsbloom/next`, and any port to
6
+ * another language) must reproduce these bytes exactly, and the signer in
7
+ * `@agentsbloom/agent` must produce them.
8
+ *
9
+ * ---------------------------------------------------------------------------
10
+ * Two profiles
11
+ * ---------------------------------------------------------------------------
12
+ *
13
+ * `ab2` (default, RFC 9421 conformant)
14
+ * "@method": UPPERCASE, as RFC 9421 §2.2.1 requires.
15
+ * "@authority": lowercased host with the scheme's default port removed
16
+ * (§2.2.3). Binding the authority is what stops a signature
17
+ * captured at store A from replaying at store B.
18
+ * "@path": the absolute path ONLY (§2.2.6) — never the query.
19
+ * "@query": "?" + query string, or a bare "?" when absent (§2.2.7).
20
+ * Headers: obs-fold collapsed, trimmed, multiple values joined with
21
+ * ", "; a covered header that is ABSENT is a hard failure.
22
+ *
23
+ * `ab1` (legacy, opt-in via `signature.acceptLegacyProfile`)
24
+ * Byte-for-byte reproduction of the pre-hardening base that shipped in
25
+ * `@agentsbloom/sdk` <= 0.5.0: lowercased `@method`, `@path` carrying the
26
+ * query, raw Host header for `@authority`, and a missing covered header
27
+ * silently canonicalized to the empty string. It exists only so a
28
+ * deployment can keep older agents working during a migration window. It
29
+ * is NOT enabled by default, and it cannot bind the authority reliably.
30
+ *
31
+ * Why the old base was wrong (all three are real interop/security defects):
32
+ * - lowercased `@method` meant no standards-conformant client could ever
33
+ * verify against us, and vice versa;
34
+ * - `@path` built from `req.originalUrl` silently included the query, so a
35
+ * conformant client signing `@path` per spec failed on any URL with a
36
+ * query string;
37
+ * - `req.headers[comp] || ''` meant a covered header the client signed but
38
+ * an intermediary stripped verified against an empty value instead of
39
+ * erroring.
40
+ *
41
+ * @see https://www.rfc-editor.org/rfc/rfc9421
42
+ */
43
+
44
+ import crypto from 'crypto';
45
+ import { parseByteSequenceDictionary } from './structured-fields.js';
46
+
47
+ export const PROFILE_STRICT = 'ab2';
48
+ export const PROFILE_LEGACY = 'ab1';
49
+
50
+ /** Derived components this implementation knows how to canonicalize. */
51
+ const SUPPORTED_DERIVED = new Set([
52
+ '@method',
53
+ '@target-uri',
54
+ '@authority',
55
+ '@scheme',
56
+ '@request-target',
57
+ '@path',
58
+ '@query',
59
+ '@query-param',
60
+ ]);
61
+
62
+ /**
63
+ * Component parameters we deliberately refuse.
64
+ *
65
+ * `sf`/`bs`/`key`/`tr` change the canonical value of a component; `req`
66
+ * re-targets it at a different message. Accepting the parameter name while
67
+ * ignoring its meaning — which the old regex parser did — means the signer
68
+ * and the verifier canonicalize different bytes while both believe the
69
+ * signature covered the same thing. Refusing is the only safe option until
70
+ * each is actually implemented.
71
+ */
72
+ const REJECTED_COMPONENT_PARAMS = new Set(['sf', 'bs', 'key', 'req', 'tr']);
73
+
74
+ const DEFAULT_PORTS = { 'http:': '80', 'https:': '443' };
75
+
76
+ export class SignatureBaseError extends Error {
77
+ constructor(message) {
78
+ super(message);
79
+ this.name = 'SignatureBaseError';
80
+ }
81
+ }
82
+
83
+ function fail(message) {
84
+ throw new SignatureBaseError(message);
85
+ }
86
+
87
+ /**
88
+ * Normalizes an HTTP authority per RFC 9421 §2.2.3: lowercase host, drop the
89
+ * scheme's default port. IPv6 literals keep their brackets.
90
+ *
91
+ * @param {string} rawAuthority
92
+ * @param {string} scheme - e.g. 'https:'
93
+ * @returns {string}
94
+ */
95
+ export function normalizeAuthority(rawAuthority, scheme) {
96
+ const authority = String(rawAuthority || '').trim().toLowerCase();
97
+ if (!authority) return '';
98
+ const defaultPort = DEFAULT_PORTS[scheme];
99
+ if (!defaultPort) return authority;
100
+ // Only strip a trailing :port, never a colon inside an IPv6 literal.
101
+ const lastColon = authority.lastIndexOf(':');
102
+ if (lastColon === -1) return authority;
103
+ const closingBracket = authority.lastIndexOf(']');
104
+ if (lastColon < closingBracket) return authority;
105
+ const port = authority.slice(lastColon + 1);
106
+ if (port === defaultPort) return authority.slice(0, lastColon);
107
+ return authority;
108
+ }
109
+
110
+ /**
111
+ * Canonicalizes one header field value for the signature base
112
+ * (RFC 9421 §2.1): obs-fold collapsed to a single space, leading/trailing
113
+ * whitespace removed, repeated fields joined with ", ".
114
+ *
115
+ * @param {string|string[]|undefined} value
116
+ * @returns {string|undefined} undefined when the header is absent
117
+ */
118
+ function canonicalizeHeaderValue(value) {
119
+ if (value === undefined || value === null) return undefined;
120
+ const values = Array.isArray(value) ? value : [value];
121
+ if (values.length === 0) return undefined;
122
+ return values
123
+ .map((entry) => String(entry).replace(/[\r\n]+[ \t]+/g, ' ').replace(/[\r\n]/g, '').trim())
124
+ .join(', ');
125
+ }
126
+
127
+ /**
128
+ * A runtime-neutral view of the request being verified. Keeping the shape
129
+ * explicit (rather than passing an Express `req` around) is what lets the
130
+ * Edge/WebCrypto port in `@agentsbloom/next` share these exact rules.
131
+ *
132
+ * @typedef {object} SignatureRequestContext
133
+ * @property {string} method
134
+ * @property {string} scheme - 'http:' or 'https:'
135
+ * @property {string} authority - raw Host/:authority value
136
+ * @property {string} path - absolute path, no query
137
+ * @property {string} query - query string WITHOUT the leading '?'
138
+ * @property {(name: string) => string|string[]|undefined} header
139
+ * @property {string} [legacyTarget] - `req.originalUrl` equivalent, ab1 only
140
+ */
141
+
142
+ /**
143
+ * Builds a {@link SignatureRequestContext} from an Express request.
144
+ *
145
+ * @param {object} req
146
+ * @param {object} [options]
147
+ * @param {string} [options.forwardedProto] - already-validated proto ('http'|'https'|'')
148
+ * @returns {SignatureRequestContext}
149
+ */
150
+ export function requestContextFromExpress(req, options = {}) {
151
+ const rawTarget = req.originalUrl || req.url || req.path || '/';
152
+ const queryIndex = rawTarget.indexOf('?');
153
+ const path = queryIndex === -1 ? rawTarget : rawTarget.slice(0, queryIndex);
154
+ const query = queryIndex === -1 ? '' : rawTarget.slice(queryIndex + 1);
155
+ const proto = options.forwardedProto || req.protocol || 'http';
156
+ return {
157
+ method: String(req.method || ''),
158
+ scheme: `${proto}:`,
159
+ authority: Array.isArray(req.headers?.host) ? req.headers.host[0] : req.headers?.host || '',
160
+ path: path || '/',
161
+ query,
162
+ header: (name) => req.headers?.[name],
163
+ legacyTarget: rawTarget,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Canonical value of a derived (`@`-prefixed) component.
169
+ *
170
+ * @param {string} name
171
+ * @param {Map<string, unknown>} params
172
+ * @param {SignatureRequestContext} request
173
+ * @param {string} profile
174
+ * @returns {string}
175
+ */
176
+ function derivedComponentValue(name, params, request, profile) {
177
+ const legacy = profile === PROFILE_LEGACY;
178
+ switch (name) {
179
+ case '@method':
180
+ // RFC 9421 §2.2.1: the method, uppercase. ab1 lowercased it.
181
+ return legacy ? String(request.method).toLowerCase() : String(request.method).toUpperCase();
182
+ case '@authority':
183
+ return legacy
184
+ ? String(request.authority ?? '')
185
+ : normalizeAuthority(request.authority, request.scheme);
186
+ case '@scheme':
187
+ return String(request.scheme || '').replace(/:$/, '').toLowerCase();
188
+ case '@path':
189
+ // RFC 9421 §2.2.6: absolute path only. ab1 used path+query.
190
+ if (legacy) return String(request.legacyTarget ?? request.path ?? '/');
191
+ return request.path && request.path.length > 0 ? request.path : '/';
192
+ case '@query':
193
+ // RFC 9421 §2.2.7: "?" + query; a bare "?" when the query is absent.
194
+ return request.query ? `?${request.query}` : '?';
195
+ case '@request-target':
196
+ return request.query ? `${request.path}?${request.query}` : request.path;
197
+ case '@target-uri': {
198
+ const authority = normalizeAuthority(request.authority, request.scheme);
199
+ if (!authority) fail('@target-uri cannot be derived without an authority');
200
+ const scheme = String(request.scheme || '').replace(/:$/, '');
201
+ return `${scheme}://${authority}${request.path}${request.query ? `?${request.query}` : ''}`;
202
+ }
203
+ case '@query-param': {
204
+ const paramName = params.get('name');
205
+ if (typeof paramName !== 'string') {
206
+ fail('@query-param requires a string "name" parameter');
207
+ }
208
+ const search = new URLSearchParams(request.query || '');
209
+ const values = search.getAll(paramName);
210
+ if (values.length === 0) fail(`@query-param "${paramName}" is not present in the request`);
211
+ if (values.length > 1) fail(`@query-param "${paramName}" appears more than once`);
212
+ // RFC 9421 §2.2.8 canonicalizes the param name and value
213
+ // percent-encoded; URLSearchParams has already decoded, so re-encode.
214
+ return encodeURIComponent(values[0]);
215
+ }
216
+ default:
217
+ return fail(`unsupported derived component "${name}"`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Builds the RFC 9421 signature base.
223
+ *
224
+ * @param {object} options
225
+ * @param {Array<{ name: string, params: Map<string, unknown> }>} options.components
226
+ * Covered component identifiers in the order the client listed them.
227
+ * @param {string} options.signatureParamsRaw
228
+ * The EXACT `Signature-Input` dictionary-member value as received. Per
229
+ * RFC 9421 §2.5 the `@signature-params` line reproduces what was sent;
230
+ * re-serializing from a parse tree would reject signers whose parameter
231
+ * ordering or spelling differs from ours.
232
+ * @param {SignatureRequestContext} options.request
233
+ * @param {string} [options.profile]
234
+ * @returns {string}
235
+ */
236
+ export function buildSignatureBase({ components, signatureParamsRaw, request, profile = PROFILE_STRICT }) {
237
+ if (!Array.isArray(components) || components.length === 0) {
238
+ fail('the covered component list is empty');
239
+ }
240
+ if (typeof signatureParamsRaw !== 'string' || signatureParamsRaw.length === 0) {
241
+ fail('the signature parameters are missing');
242
+ }
243
+
244
+ const seen = new Set();
245
+ let base = '';
246
+
247
+ for (const component of components) {
248
+ const rawName = component.name;
249
+ if (typeof rawName !== 'string' || rawName.length === 0) {
250
+ fail('a covered component identifier is not a string');
251
+ }
252
+ // RFC 9421 §2.1: component names are lowercase. Reject rather than
253
+ // silently fold, so a signer that sent `Content-Digest` learns why.
254
+ if (rawName !== rawName.toLowerCase()) {
255
+ fail(`covered component "${rawName}" must be lowercase`);
256
+ }
257
+
258
+ const params = component.params instanceof Map ? component.params : new Map();
259
+ for (const paramName of params.keys()) {
260
+ if (REJECTED_COMPONENT_PARAMS.has(paramName)) {
261
+ fail(`covered component parameter ";${paramName}" is not supported`);
262
+ }
263
+ if (paramName === 'name' && rawName !== '@query-param') {
264
+ fail('the ";name" parameter is only valid on @query-param');
265
+ }
266
+ if (paramName !== 'name') {
267
+ fail(`unknown covered component parameter ";${paramName}"`);
268
+ }
269
+ }
270
+
271
+ // A component may repeat only when its parameters differ (RFC 9421 §2.1).
272
+ const identity = params.has('name') ? `${rawName};name=${params.get('name')}` : rawName;
273
+ if (seen.has(identity)) fail(`covered component ${identity} appears more than once`);
274
+ seen.add(identity);
275
+
276
+ let value;
277
+ if (rawName.startsWith('@')) {
278
+ if (!SUPPORTED_DERIVED.has(rawName)) fail(`unsupported derived component "${rawName}"`);
279
+ if (rawName === '@signature-params') fail('@signature-params cannot be listed explicitly');
280
+ value = derivedComponentValue(rawName, params, request, profile);
281
+ } else {
282
+ if (!/^[!#$%&'*+\-.^_`|~0-9a-z]+$/.test(rawName)) {
283
+ fail(`covered component "${rawName}" is not a valid header field name`);
284
+ }
285
+ const canonical = canonicalizeHeaderValue(request.header(rawName));
286
+ if (canonical === undefined) {
287
+ if (profile === PROFILE_LEGACY) {
288
+ // ab1 bug-compatibility: absent headers canonicalized to ''.
289
+ value = '';
290
+ } else {
291
+ fail(`covered header "${rawName}" is missing from the request`);
292
+ }
293
+ } else {
294
+ value = canonical;
295
+ }
296
+ }
297
+
298
+ // Serialize the component identifier the way the client did, including
299
+ // its parameters, so the base line matches byte-for-byte.
300
+ const serializedName = params.has('name')
301
+ ? `"${rawName}";name="${String(params.get('name')).replace(/(["\\])/g, '\\$1')}"`
302
+ : `"${rawName}"`;
303
+ base += `${serializedName}: ${value}\n`;
304
+ }
305
+
306
+ base += `"@signature-params": ${signatureParamsRaw}`;
307
+ return base;
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Content-Digest
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /** Digest algorithms we compute and accept, per RFC 9530. */
315
+ const DIGEST_ALGORITHMS = {
316
+ 'sha-256': 'sha256',
317
+ 'sha-512': 'sha512',
318
+ };
319
+
320
+ /**
321
+ * Computes the `Content-Digest` header value for a body.
322
+ *
323
+ * @param {Buffer} bodyBytes
324
+ * @param {string} [algorithm='sha-256']
325
+ * @returns {string} e.g. `sha-256=:<base64>:`
326
+ */
327
+ export function contentDigestHeader(bodyBytes, algorithm = 'sha-256') {
328
+ const nodeAlg = DIGEST_ALGORITHMS[algorithm];
329
+ if (!nodeAlg) fail(`unsupported content-digest algorithm "${algorithm}"`);
330
+ const digest = crypto.createHash(nodeAlg).update(bodyBytes).digest('base64');
331
+ return `${algorithm}=:${digest}:`;
332
+ }
333
+
334
+ /**
335
+ * Verifies a received `Content-Digest` header against the actual body bytes.
336
+ *
337
+ * Rules:
338
+ * - the header must parse as a Structured Field dictionary of byte
339
+ * sequences (`sha-256=:...:, sha-512=:...:`);
340
+ * - at least one entry must use an algorithm we support;
341
+ * - EVERY supported entry must match. Accepting a request because one of
342
+ * two digests matched would let a caller pin a weak algorithm alongside
343
+ * a bogus strong one.
344
+ * - unknown algorithms are ignored (RFC 9530 allows extension), but they
345
+ * cannot satisfy the "at least one supported" requirement.
346
+ *
347
+ * @param {string|string[]|undefined} headerValue
348
+ * @param {Buffer} bodyBytes
349
+ * @returns {{ ok: true } | { ok: false, reason: string }}
350
+ */
351
+ export function verifyContentDigest(headerValue, bodyBytes) {
352
+ const canonical = canonicalizeHeaderValue(headerValue);
353
+ if (canonical === undefined || canonical === '') {
354
+ return { ok: false, reason: 'content-digest header is missing' };
355
+ }
356
+ let entries;
357
+ try {
358
+ entries = parseByteSequenceDictionary(canonical);
359
+ } catch (err) {
360
+ return { ok: false, reason: `content-digest header is malformed: ${err.message}` };
361
+ }
362
+ let matchedSupported = 0;
363
+ for (const [algorithm, provided] of entries) {
364
+ const nodeAlg = DIGEST_ALGORITHMS[algorithm];
365
+ if (!nodeAlg) continue;
366
+ const expected = crypto.createHash(nodeAlg).update(bodyBytes).digest();
367
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
368
+ return { ok: false, reason: `content-digest ${algorithm} does not match the request body` };
369
+ }
370
+ matchedSupported += 1;
371
+ }
372
+ if (matchedSupported === 0) {
373
+ return { ok: false, reason: 'content-digest header contains no supported algorithm' };
374
+ }
375
+ return { ok: true };
376
+ }
377
+
378
+ /**
379
+ * Resolves the exact body bytes a digest must be computed over.
380
+ *
381
+ * The digest guarantee is "this is what the client sent". That is only true
382
+ * when we hash the RECEIVED BYTES. Re-serializing `req.body` with
383
+ * `JSON.stringify` — which the previous implementation fell back to — turns
384
+ * the guarantee into "this matches our re-serialization of what our parser
385
+ * produced", which an agent computing its digest the same way satisfies even
386
+ * when the bytes on the wire differed (key order, number formatting,
387
+ * duplicate keys, unicode escapes).
388
+ *
389
+ * So: when a signed request has a body, `req.rawBody` is REQUIRED. Merchants
390
+ * wire it up with one line:
391
+ *
392
+ * app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
393
+ *
394
+ * @param {object} req
395
+ * @param {object} [options]
396
+ * @param {boolean} [options.allowReserializedBody=false] - legacy escape hatch
397
+ * @returns {Buffer}
398
+ */
399
+ export function resolveBodyBytes(req, options = {}) {
400
+ const { allowReserializedBody = false } = options;
401
+
402
+ if (Buffer.isBuffer(req.rawBody)) return req.rawBody;
403
+ if (typeof req.rawBody === 'string') return Buffer.from(req.rawBody, 'utf8');
404
+ if (Buffer.isBuffer(req.body)) return req.body;
405
+
406
+ const declaredLength = Number(
407
+ (Array.isArray(req.headers?.['content-length'])
408
+ ? req.headers['content-length'][0]
409
+ : req.headers?.['content-length']) || 0,
410
+ );
411
+ const hasBody =
412
+ (Number.isFinite(declaredLength) && declaredLength > 0) ||
413
+ String(req.headers?.['transfer-encoding'] || '').toLowerCase().includes('chunked') ||
414
+ (req.body !== undefined && req.body !== null && !isEmptyObject(req.body));
415
+
416
+ if (!hasBody) return Buffer.alloc(0);
417
+
418
+ if (allowReserializedBody && req.body !== undefined) {
419
+ return Buffer.from(JSON.stringify(req.body ?? null), 'utf8');
420
+ }
421
+
422
+ fail(
423
+ 'Signed requests with a body require the raw bytes. Capture them with '
424
+ + 'express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }) — '
425
+ + 'hashing a re-serialized body would not prove what the client actually sent.',
426
+ );
427
+ }
428
+
429
+ function isEmptyObject(value) {
430
+ return (
431
+ typeof value === 'object'
432
+ && value !== null
433
+ && !Array.isArray(value)
434
+ && Object.keys(value).length === 0
435
+ );
436
+ }