@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,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
|
+
}
|