@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/lib/ap2.js CHANGED
@@ -1,422 +1,1017 @@
1
- import crypto from 'crypto';
2
-
3
- /**
4
- * Hardened AP2 (Agent Payments Protocol) mandate verification.
5
- *
6
- * This module replaces the previous "valid but unverified" bypass in
7
- * packages/sdk/index.js's verifyAP2Mandates: a mandate that could not be
8
- * cryptographically verified was still returned as `{ valid: true,
9
- * verified: false }` and the checkout gate only checked `!ap2MandateResult`
10
- * / budget, never `.verified`. That meant any attacker could hand-craft an
11
- * unsigned (or badly-signed) mandate claiming an arbitrary maxBudget and
12
- * have it treated as authorization to check out.
13
- *
14
- * The rules enforced here:
15
- * 1. If no mandate header is present at all, AP2 simply isn't in use for
16
- * this request - that's still `{ valid: true, verified: false }` so
17
- * non-AP2 traffic (REST/ACP) is never blocked by AP2 logic.
18
- * 2. If a mandate header IS present, it MUST verify successfully or the
19
- * request is rejected outright (`valid: false`). There is no
20
- * "valid but unverified" middle ground once a mandate is presented.
21
- * 3. Signature verification uses either (a) a merchant-configured
22
- * trusted public key (out-of-band trust, any of ES256/384/512,
23
- * RS256/384/512, EdDSA), or (b) self-certifying did:key issuers
24
- * (Ed25519/EdDSA only - the public key IS the issuer identifier, so
25
- * no pre-registration is needed for arbitrary agents/wallets).
26
- * 4. Mandates must carry a unique `jti` and are checked against a
27
- * bounded in-memory replay cache - a captured valid mandate cannot be
28
- * replayed to trigger a second charge.
29
- * 5. `aud` is checked against the caller-supplied expected audience
30
- * (the merchant's own base URL) when one is provided, so a mandate
31
- * signed for store A cannot be replayed against store B.
32
- * 6. Mandate lifetime (`exp - iat`) is bounded, so a "valid forever"
33
- * mandate cannot be crafted even with a real signature.
34
- */
35
-
36
- // --- base58btc (Bitcoin alphabet) - no external dependency ---
37
- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
38
- const BASE58_MAP = new Map(BASE58_ALPHABET.split('').map((c, i) => [c, i]));
39
-
40
- /** @param {Buffer} buffer @returns {string} */
41
- function base58Encode(buffer) {
42
- if (buffer.length === 0) return '';
43
- let value = 0n;
44
- for (const byte of buffer) value = value * 256n + BigInt(byte);
45
-
46
- let encoded = '';
47
- while (value > 0n) {
48
- const remainder = value % 58n;
49
- value = value / 58n;
50
- encoded = BASE58_ALPHABET[Number(remainder)] + encoded;
51
- }
52
-
53
- let leadingZeros = 0;
54
- for (const byte of buffer) {
55
- if (byte === 0) leadingZeros++;
56
- else break;
57
- }
58
- return BASE58_ALPHABET[0].repeat(leadingZeros) + encoded;
59
- }
60
-
61
- /** @param {string} str @returns {Buffer} */
62
- function base58Decode(str) {
63
- if (str.length === 0) return Buffer.alloc(0);
64
- let value = 0n;
65
- for (const char of str) {
66
- const digit = BASE58_MAP.get(char);
67
- if (digit === undefined) throw new Error(`Invalid base58 character: ${char}`);
68
- value = value * 58n + BigInt(digit);
69
- }
70
-
71
- const bytes = [];
72
- while (value > 0n) {
73
- bytes.unshift(Number(value % 256n));
74
- value = value / 256n;
75
- }
76
-
77
- let leadingZeros = 0;
78
- for (const char of str) {
79
- if (char === BASE58_ALPHABET[0]) leadingZeros++;
80
- else break;
81
- }
82
- return Buffer.concat([Buffer.alloc(leadingZeros, 0), Buffer.from(bytes)]);
83
- }
84
-
85
- // Ed25519 multicodec (0xed) varint-encoded as [0xed, 0x01], per the did:key
86
- // Ed25519 method spec (multicodec ed25519-pub prefix).
87
- const ED25519_MULTICODEC_PREFIX = Buffer.from([0xed, 0x01]);
88
-
89
- // Fixed 12-byte SPKI DER prefix for Ed25519 public keys (RFC 8410) - Ed25519
90
- // has no algorithm parameters, so the DER encoding of any Ed25519 SPKI key
91
- // is always this fixed prefix followed by the raw 32-byte public key.
92
- const ED25519_SPKI_DER_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
93
-
94
- /**
95
- * Derives a did:key identifier from an Ed25519 public key.
96
- * @param {crypto.KeyObject|Buffer} publicKey - a Node KeyObject or raw 32-byte public key
97
- * @returns {string} e.g. "did:key:z6Mk..."
98
- */
99
- export function didKeyFromEd25519PublicKey(publicKey) {
100
- const rawKey = Buffer.isBuffer(publicKey)
101
- ? publicKey
102
- : publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
103
- const prefixed = Buffer.concat([ED25519_MULTICODEC_PREFIX, rawKey]);
104
- return `did:key:z${base58Encode(prefixed)}`;
105
- }
106
-
107
- /**
108
- * Derives an Ed25519 public KeyObject from a did:key identifier.
109
- * @param {string} didKey - e.g. "did:key:z6Mk..."
110
- * @returns {crypto.KeyObject|null} null if the identifier is not a
111
- * well-formed Ed25519 did:key (caller treats this as "cannot verify")
112
- */
113
- export function ed25519PublicKeyFromDidKey(didKey) {
114
- if (typeof didKey !== 'string' || !didKey.startsWith('did:key:z')) return null;
115
- try {
116
- const multibase = didKey.slice('did:key:'.length);
117
- const decoded = base58Decode(multibase.slice(1)); // drop leading 'z' multibase prefix
118
- if (
119
- decoded.length !== ED25519_MULTICODEC_PREFIX.length + 32 ||
120
- !decoded.subarray(0, 2).equals(ED25519_MULTICODEC_PREFIX)
121
- ) {
122
- return null;
123
- }
124
- const rawKey = decoded.subarray(2);
125
- const der = Buffer.concat([ED25519_SPKI_DER_PREFIX, rawKey]);
126
- return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
127
- } catch {
128
- return null;
129
- }
130
- }
131
-
132
- // --- Replay protection ---
133
- // A bounded, TTL-cleaned cache of mandate `jti`s that have already been
134
- // consumed. Sized generously; entries are removed once the mandate they
135
- // belonged to would have expired anyway, so the cache cannot grow forever
136
- // even under sustained attack traffic.
137
- const seenMandateJtis = new Map(); // jti -> expiresAtMs
138
- const REPLAY_CACHE_MAX_SIZE = 50_000;
139
-
140
- const replayCleanupInterval = setInterval(() => {
141
- const now = Date.now();
142
- for (const [jti, expiresAtMs] of seenMandateJtis.entries()) {
143
- if (expiresAtMs < now) seenMandateJtis.delete(jti);
144
- }
145
- }, 60 * 1000);
146
- replayCleanupInterval.unref?.();
147
-
148
- /** Clears all replay-tracking state. Exposed for tests and shutdown(). */
149
- export function resetAp2ReplayCache() {
150
- seenMandateJtis.clear();
151
- }
152
-
153
- /** Stops the background cleanup timer. Called from index.js's shutdown(). */
154
- export function stopAp2ReplayCleanup() {
155
- clearInterval(replayCleanupInterval);
156
- }
157
-
158
- function isReplay(jti, expMs) {
159
- if (seenMandateJtis.has(jti)) return true;
160
- if (seenMandateJtis.size < REPLAY_CACHE_MAX_SIZE) {
161
- seenMandateJtis.set(jti, expMs);
162
- }
163
- return false;
164
- }
165
-
166
- // --- Signature verification helpers ---
167
-
168
- /**
169
- * Algorithms accepted when the merchant has configured an explicit,
170
- * out-of-band-trusted public key. The merchant already trusts this key by
171
- * configuring it, so we support the same breadth of algorithms the
172
- * original implementation advertised.
173
- */
174
- const EXPLICIT_KEY_ALLOWED_ALGS = new Set(['EdDSA', 'ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512']);
175
-
176
- function hashAlgForJwtAlg(alg) {
177
- if (alg === 'EdDSA') return null; // Ed25519/Ed448 signature algorithm is built into the key
178
- if (alg.includes('384')) return 'SHA384';
179
- if (alg.includes('512')) return 'SHA512';
180
- return 'SHA256';
181
- }
182
-
183
- /**
184
- * Verifies an AP2 mandate SD-JWT presented in an incoming request.
185
- *
186
- * @param {Record<string, string>} [headers]
187
- * @param {Record<string, unknown>} [body]
188
- * @param {object} [options]
189
- * @param {crypto.KeyObject|Buffer|string|null} [options.trustedPublicKey] - merchant-configured
190
- * out-of-band-trusted key (any format accepted by crypto.createPublicKey, or an
191
- * already-constructed KeyObject). When set, this key is used instead of deriving
192
- * one from a did:key issuer, and the broader algorithm set is permitted.
193
- * @param {string} [options.expectedAudience] - if set, `aud` must equal this value
194
- * @param {number} [options.maxMandateLifetimeSec=3600] - maximum allowed `exp - iat`
195
- * @param {boolean} [options.requireJti=true] - reject mandates without a `jti` claim
196
- * @param {string[]} [options.requestedCategories] - if set and the mandate's
197
- * `intentMandate.allowedCategories` is present, the two must overlap
198
- * @returns {{ valid: boolean, verified: boolean, protocol: 'AP2', reason?: string, mandates?: object }}
199
- */
200
- export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
201
- const {
202
- trustedPublicKey = null,
203
- expectedAudience = null,
204
- maxMandateLifetimeSec = 3600,
205
- requireJti = true,
206
- requestedCategories = null,
207
- } = options;
208
-
209
- const mandateHeader = headers['x-ap2-mandate'] || headers['authorization'];
210
- if (!mandateHeader) {
211
- // AP2 simply isn't in use for this request - not a security decision.
212
- return { valid: true, protocol: 'AP2', verified: false, note: 'No AP2 Mandates attached' };
213
- }
214
-
215
- let header, payload, signatureB64;
216
- try {
217
- const token = String(mandateHeader).replace(/^Bearer\s+/i, '');
218
- const parts = token.split('.');
219
- if (parts.length < 3) return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
220
- header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
221
- payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
222
- signatureB64 = parts[2];
223
- } catch {
224
- return { valid: false, reason: 'Invalid AP2 Mandate SD-JWT format', protocol: 'AP2' };
225
- }
226
-
227
- // --- Required claims present ---
228
- if (!payload.iss) return { valid: false, reason: 'Missing issuer', protocol: 'AP2' };
229
- if (!payload.aud) return { valid: false, reason: 'Missing audience', protocol: 'AP2' };
230
- if (typeof payload.iat !== 'number') return { valid: false, reason: 'Missing iat', protocol: 'AP2' };
231
- if (typeof payload.exp !== 'number') return { valid: false, reason: 'Missing exp', protocol: 'AP2' };
232
- if (requireJti && !payload.jti) {
233
- return { valid: false, reason: 'Missing jti (required for replay protection)', protocol: 'AP2' };
234
- }
235
-
236
- // --- Temporal validity ---
237
- const now = Math.floor(Date.now() / 1000);
238
- if (payload.exp < now) return { valid: false, reason: 'Token expired', protocol: 'AP2' };
239
- if (payload.iat > now) return { valid: false, reason: 'Token issued in the future', protocol: 'AP2' };
240
-
241
- // --- Bounded mandate lifetime ---
242
- if (payload.exp - payload.iat > maxMandateLifetimeSec) {
243
- return { valid: false, reason: `Mandate lifetime exceeds maximum allowed (${maxMandateLifetimeSec}s)`, protocol: 'AP2' };
244
- }
245
-
246
- // --- Resolve verification key + algorithm policy ---
247
- let publicKey = null;
248
- let usingSelfCertifyingKey = false;
249
-
250
- if (trustedPublicKey) {
251
- try {
252
- publicKey = trustedPublicKey instanceof crypto.KeyObject
253
- ? trustedPublicKey
254
- : crypto.createPublicKey(trustedPublicKey);
255
- } catch {
256
- return { valid: false, reason: 'Configured trusted public key is invalid', protocol: 'AP2' };
257
- }
258
- if (!EXPLICIT_KEY_ALLOWED_ALGS.has(header.alg)) {
259
- return { valid: false, reason: `Unsupported signature algorithm: ${header.alg}`, protocol: 'AP2' };
260
- }
261
- } else if (String(payload.iss).startsWith('did:key:')) {
262
- usingSelfCertifyingKey = true;
263
- if (header.alg !== 'EdDSA') {
264
- return { valid: false, reason: 'did:key issuers require the EdDSA algorithm', protocol: 'AP2' };
265
- }
266
- publicKey = ed25519PublicKeyFromDidKey(payload.iss);
267
- if (!publicKey) {
268
- return { valid: false, reason: 'Issuer is not a valid Ed25519 did:key identifier', protocol: 'AP2' };
269
- }
270
- } else {
271
- // No merchant-trusted key configured, and the issuer isn't a
272
- // self-certifying did:key we can derive a key from - there is no way
273
- // to verify this mandate, so it must be rejected rather than passed
274
- // through as "valid but unverified".
275
- return {
276
- valid: false,
277
- reason: 'Cannot verify mandate: issuer is not a did:key and no trusted public key is configured',
278
- protocol: 'AP2',
279
- };
280
- }
281
-
282
- // --- Signature verification (mandatory) ---
283
- try {
284
- const [encodedHeader, encodedPayload] = String(mandateHeader).replace(/^Bearer\s+/i, '').split('.');
285
- const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`);
286
- const signature = Buffer.from(signatureB64, 'base64url');
287
- const hashAlg = hashAlgForJwtAlg(header.alg);
288
- const isValid = crypto.verify(hashAlg, signingInput, publicKey, signature);
289
- if (!isValid) return { valid: false, reason: 'Invalid signature', protocol: 'AP2' };
290
- } catch (err) {
291
- return { valid: false, reason: `Signature verification failed: ${err.message}`, protocol: 'AP2' };
292
- }
293
-
294
- // --- Audience binding ---
295
- if (expectedAudience && payload.aud !== expectedAudience) {
296
- return {
297
- valid: false,
298
- reason: `Mandate audience "${payload.aud}" does not match this store ("${expectedAudience}")`,
299
- protocol: 'AP2',
300
- };
301
- }
302
-
303
- // --- merchantScope binding (if the mandate declares one, it must match too) ---
304
- const merchantScope = payload.intentMandate?.merchantScope;
305
- if (expectedAudience && merchantScope && merchantScope !== expectedAudience) {
306
- return {
307
- valid: false,
308
- reason: `Mandate merchantScope "${merchantScope}" does not match this store ("${expectedAudience}")`,
309
- protocol: 'AP2',
310
- };
311
- }
312
-
313
- // --- Replay protection ---
314
- if (payload.jti) {
315
- if (isReplay(payload.jti, payload.exp * 1000)) {
316
- return { valid: false, reason: 'Mandate has already been used (replay detected)', protocol: 'AP2' };
317
- }
318
- }
319
-
320
- // --- Category enforcement (only if both the mandate and caller supply it) ---
321
- const allowedCategories = payload.intentMandate?.allowedCategories;
322
- if (Array.isArray(allowedCategories) && Array.isArray(requestedCategories) && requestedCategories.length > 0) {
323
- const overlaps = requestedCategories.some((c) => allowedCategories.includes(c));
324
- if (!overlaps) {
325
- return {
326
- valid: false,
327
- reason: `Requested categories [${requestedCategories.join(', ')}] are not within the mandate's allowedCategories [${allowedCategories.join(', ')}]`,
328
- protocol: 'AP2',
329
- };
330
- }
331
- }
332
-
333
- // --- Budget enforcement (checked here for the common case where the
334
- // caller already knows the order total; the SDK middleware also
335
- // re-checks this at the /ap2/checkout call site once the cart total is known) ---
336
- const maxBudget = payload.intentMandate?.maxBudget ?? payload.paymentMandate?.maxBudget;
337
- if (maxBudget !== undefined && body?.orderTotal !== undefined) {
338
- if (Number(body.orderTotal) > Number(maxBudget)) {
339
- return { valid: false, reason: 'Order total exceeds Intent Mandate maxBudget limit', protocol: 'AP2' };
340
- }
341
- }
342
-
343
- return {
344
- valid: true,
345
- protocol: 'AP2',
346
- verified: true,
347
- selfCertifying: usingSelfCertifyingKey,
348
- mandates: payload,
349
- };
350
- }
351
-
352
- /**
353
- * Builds and signs a complete AP2 Intent+Payment Mandate SD-JWT for
354
- * agent-side tooling (test harnesses, demos, or a real agent's own wallet
355
- * integration). The issuer is a did:key derived from the signing keypair,
356
- * so the resulting mandate self-certifies without any prior key
357
- * registration with the merchant.
358
- *
359
- * @param {object} options
360
- * @param {string} options.audience - the merchant/store base URL this mandate authorizes (required)
361
- * @param {number} options.maxBudget - spending cap enforced by the merchant on checkout
362
- * @param {string} [options.currency='USD']
363
- * @param {string[]} [options.allowedCategories] - optional category allowlist
364
- * @param {string} [options.merchantScope] - defaults to `audience`
365
- * @param {number} [options.lifetimeSec=3600] - mandate validity window
366
- * @param {string} [options.paymentMethod='tokenized_card']
367
- * @param {string} [options.subject='user_wallet_delegation']
368
- * @param {crypto.KeyObject} [options.privateKey] - reuse an existing Ed25519 private key;
369
- * a fresh ephemeral keypair is generated if omitted
370
- * @returns {{ token: string, did: string, publicKey: crypto.KeyObject, privateKey: crypto.KeyObject }}
371
- */
372
- export function createAp2Mandate(options = {}) {
373
- const {
374
- audience,
375
- maxBudget,
376
- currency = 'USD',
377
- allowedCategories,
378
- merchantScope,
379
- lifetimeSec = 3600,
380
- paymentMethod = 'tokenized_card',
381
- subject = 'user_wallet_delegation',
382
- privateKey: providedPrivateKey,
383
- } = options;
384
-
385
- if (!audience) throw new Error('createAp2Mandate requires an `audience` (the target store base URL)');
386
- if (maxBudget === undefined) throw new Error('createAp2Mandate requires a `maxBudget`');
387
-
388
- const { privateKey, publicKey } = providedPrivateKey
389
- ? { privateKey: providedPrivateKey, publicKey: crypto.createPublicKey(providedPrivateKey) }
390
- : crypto.generateKeyPairSync('ed25519');
391
-
392
- const did = didKeyFromEd25519PublicKey(publicKey);
393
- const iat = Math.floor(Date.now() / 1000);
394
- const exp = iat + lifetimeSec;
395
-
396
- const header = { alg: 'EdDSA', typ: 'JWT' };
397
- const payload = {
398
- iss: did,
399
- aud: audience,
400
- sub: subject,
401
- iat,
402
- exp,
403
- jti: crypto.randomUUID(),
404
- intentMandate: {
405
- maxBudget,
406
- currency,
407
- merchantScope: merchantScope || audience,
408
- ...(allowedCategories ? { allowedCategories } : {}),
409
- },
410
- paymentMandate: {
411
- paymentMethod,
412
- currency,
413
- },
414
- };
415
-
416
- const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
417
- const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
418
- const signingInput = `${encodedHeader}.${encodedPayload}`;
419
- const signature = crypto.sign(null, Buffer.from(signingInput), privateKey).toString('base64url');
420
-
421
- return { token: `${signingInput}.${signature}`, did, publicKey, privateKey };
422
- }
1
+ import crypto from 'crypto';
2
+ import { createReplayCache } from './shared-store.js';
3
+ import {
4
+ compareAmounts,
5
+ normalizeCurrencyCode,
6
+ parseAmount,
7
+ sumLineItems,
8
+ toHundredths,
9
+ } from './money.js';
10
+
11
+ /**
12
+ * Hardened AP2 (Agent Payments Protocol) mandate verification.
13
+ *
14
+ * A mandate is a cryptographic spending authorization. If this file is wrong,
15
+ * money moves that should not have. The rules enforced here:
16
+ *
17
+ * 1. No mandate header at all => AP2 simply isn't in use for this request:
18
+ * `{ valid: true, verified: false }`, so plain REST/ACP traffic is never
19
+ * blocked by AP2 logic. Callers that GATE on a mandate must therefore
20
+ * check `.verified`, never mere truthiness.
21
+ * 2. A mandate that IS presented MUST verify cryptographically or the
22
+ * request is rejected. There is no "valid but unverified" middle ground.
23
+ * 3. Verification uses either a merchant-configured trusted public key
24
+ * (out-of-band trust) or a self-certifying `did:key` issuer, where the
25
+ * public key IS the issuer identifier.
26
+ * 4. Mandates carry a unique `jti` and are single-use against an atomic
27
+ * replay claim, consumed only after every other check passes so a
28
+ * rejected attempt does not brick the corrected retry.
29
+ * 5. `aud` (and `intentMandate.merchantScope`) bind the mandate to one
30
+ * store, so a mandate signed for store A cannot be replayed at store B.
31
+ * 6. `exp - iat` is bounded, so a "valid forever" mandate cannot be minted.
32
+ *
33
+ * ---------------------------------------------------------------------------
34
+ * What this hardening pass changed
35
+ * ---------------------------------------------------------------------------
36
+ *
37
+ * - **ECDSA now verifies.** `crypto.verify(hash, data, key, sig)` expects DER;
38
+ * JOSE `ES256`/`ES384`/`ES512` signatures are raw `r||s`. Every genuine
39
+ * ECDSA-signed mandate previously failed, while the algorithm allow-list and
40
+ * `/ap2/capabilities` both advertised support. Fixed with
41
+ * `dsaEncoding: 'ieee-p1363'` plus explicit curve pinning.
42
+ * - **`alg` is pinned to the trusted key.** Membership in an allow-list is not
43
+ * a binding: an `ES256` header against an RSA trusted key used to reach the
44
+ * crypto call and merely fail, rather than being refused as a policy error.
45
+ * RSA keys below 2048 bits are refused.
46
+ * - **Money is exact.** `Number(orderTotal) > Number(maxBudget)` compared
47
+ * IEEE-754 doubles, and `Number('abc')` is `NaN`, for which every comparison
48
+ * is false a malformed total PASSED the budget check. Amounts now parse
49
+ * strictly and compare as exact integers (see ./money.js).
50
+ * - **Unitless budgets are rejected.** Every currency check used to be
51
+ * conditional on the mandate declaring a currency, so a mandate with
52
+ * `maxBudget` and no `currency` bypassed all of them.
53
+ * - **Clock skew is tolerated.** `iat`/`exp` were compared with zero
54
+ * tolerance, rejecting legitimate mandates from any client a second fast.
55
+ * - **The envelope is strict.** `parts.length >= 3` accepted a 5-segment
56
+ * JWE-shaped token and treated part 3 as the signature; `crit` was ignored;
57
+ * SD-JWT `~` disclosures were silently truncated. All now refused.
58
+ * - **Rejections carry a `code` and a `disclose` flag** so the HTTP layer can
59
+ * tell an agent "your budget is too low" (actionable) without telling it
60
+ * "this store expects audience X" or echoing raw crypto error text.
61
+ * - **`jti` is validated** before it becomes a cache key, and the cart hash
62
+ * totals are overflow-checked.
63
+ */
64
+
65
+ // --- base58btc (Bitcoin alphabet) - no external dependency ---
66
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
67
+ const BASE58_MAP = new Map(BASE58_ALPHABET.split('').map((c, i) => [c, i]));
68
+ /**
69
+ * base58 decoding is O(n^2) in BigInt divisions. A did:key for Ed25519 is
70
+ * always 48 characters, so anything materially longer is either a mistake or
71
+ * an attempt to burn CPU.
72
+ */
73
+ const MAX_BASE58_LENGTH = 128;
74
+
75
+ /** @param {Buffer} buffer @returns {string} */
76
+ function base58Encode(buffer) {
77
+ if (buffer.length === 0) return '';
78
+ let value = 0n;
79
+ for (const byte of buffer) value = value * 256n + BigInt(byte);
80
+
81
+ let encoded = '';
82
+ while (value > 0n) {
83
+ const remainder = value % 58n;
84
+ value /= 58n;
85
+ encoded = BASE58_ALPHABET[Number(remainder)] + encoded;
86
+ }
87
+
88
+ let leadingZeros = 0;
89
+ for (const byte of buffer) {
90
+ if (byte === 0) leadingZeros++;
91
+ else break;
92
+ }
93
+ return BASE58_ALPHABET[0].repeat(leadingZeros) + encoded;
94
+ }
95
+
96
+ /** @param {string} str @returns {Buffer} */
97
+ function base58Decode(str) {
98
+ if (str.length === 0) return Buffer.alloc(0);
99
+ if (str.length > MAX_BASE58_LENGTH) throw new Error('base58 input is too long');
100
+ let value = 0n;
101
+ for (const char of str) {
102
+ const digit = BASE58_MAP.get(char);
103
+ if (digit === undefined) throw new Error(`Invalid base58 character: ${char}`);
104
+ value = value * 58n + BigInt(digit);
105
+ }
106
+
107
+ const bytes = [];
108
+ while (value > 0n) {
109
+ bytes.unshift(Number(value % 256n));
110
+ value /= 256n;
111
+ }
112
+
113
+ let leadingZeros = 0;
114
+ for (const char of str) {
115
+ if (char === BASE58_ALPHABET[0]) leadingZeros++;
116
+ else break;
117
+ }
118
+ return Buffer.concat([Buffer.alloc(leadingZeros, 0), Buffer.from(bytes)]);
119
+ }
120
+
121
+ // Ed25519 multicodec (0xed) varint-encoded as [0xed, 0x01], per the did:key
122
+ // Ed25519 method spec (multicodec ed25519-pub prefix).
123
+ const ED25519_MULTICODEC_PREFIX = Buffer.from([0xed, 0x01]);
124
+
125
+ // Fixed 12-byte SPKI DER prefix for Ed25519 public keys (RFC 8410) - Ed25519
126
+ // has no algorithm parameters, so the DER encoding of any Ed25519 SPKI key is
127
+ // always this fixed prefix followed by the raw 32-byte public key.
128
+ const ED25519_SPKI_DER_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
129
+
130
+ /**
131
+ * Derives a did:key identifier from an Ed25519 public key.
132
+ * @param {crypto.KeyObject|Buffer} publicKey
133
+ * @returns {string} e.g. "did:key:z6Mk..."
134
+ */
135
+ export function didKeyFromEd25519PublicKey(publicKey) {
136
+ const rawKey = Buffer.isBuffer(publicKey)
137
+ ? publicKey
138
+ : publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
139
+ const prefixed = Buffer.concat([ED25519_MULTICODEC_PREFIX, rawKey]);
140
+ return `did:key:z${base58Encode(prefixed)}`;
141
+ }
142
+
143
+ /**
144
+ * Derives an Ed25519 public KeyObject from a did:key identifier.
145
+ * @param {string} didKey
146
+ * @returns {crypto.KeyObject|null} null when the identifier is not a
147
+ * well-formed Ed25519 did:key (callers treat this as "cannot verify")
148
+ */
149
+ export function ed25519PublicKeyFromDidKey(didKey) {
150
+ if (typeof didKey !== 'string' || !didKey.startsWith('did:key:z')) return null;
151
+ try {
152
+ const multibase = didKey.slice('did:key:'.length);
153
+ const decoded = base58Decode(multibase.slice(1)); // drop leading 'z' multibase prefix
154
+ if (
155
+ decoded.length !== ED25519_MULTICODEC_PREFIX.length + 32
156
+ || !decoded.subarray(0, 2).equals(ED25519_MULTICODEC_PREFIX)
157
+ ) {
158
+ return null;
159
+ }
160
+ const rawKey = decoded.subarray(2);
161
+ const der = Buffer.concat([ED25519_SPKI_DER_PREFIX, rawKey]);
162
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // --- Replay protection ---
169
+ // Consumed mandate jtis. Local Map by default; when an Upstash Redis REST
170
+ // endpoint is configured (see lib/shared-store.js) the cache becomes
171
+ // cluster-wide so a mandate consumed on one instance cannot replay against
172
+ // another. `claim()` is maybe-async (boolean locally, Promise when shared).
173
+ const seenMandateJtis = createReplayCache('agentsbloom:ap2:jti', 50_000);
174
+
175
+ /** Clears all replay-tracking state. Exposed for tests and shutdown(). */
176
+ export function resetAp2ReplayCache() {
177
+ seenMandateJtis.clear();
178
+ }
179
+
180
+ /** Replay-cache diagnostics (local size, evictions, shared-store posture). */
181
+ export function ap2ReplayCacheStats() {
182
+ return seenMandateJtis.stats();
183
+ }
184
+
185
+ /** No-op retained for shutdown() compatibility; eviction is amortized. */
186
+ export function stopAp2ReplayCleanup() {}
187
+
188
+ /**
189
+ * Normalizes an audience/merchantScope value for comparison.
190
+ *
191
+ * Clients that build URLs with `new URL(...).href` produce a trailing slash
192
+ * while merchants configure the same audience without one, and a default port
193
+ * may or may not be present (`https://x` vs `https://x:443`). Comparing raw
194
+ * strings rejected perfectly valid mandates, so both sides are normalized:
195
+ * lowercased host, default port for the scheme removed, trailing slashes
196
+ * stripped. Non-URL audience strings keep the plain slash-stripped behavior.
197
+ *
198
+ * @param {string} value
199
+ * @returns {string}
200
+ */
201
+ export function normalizeAudience(value) {
202
+ if (typeof value !== 'string') return String(value ?? '');
203
+ const trimmed = value.trim();
204
+ if (trimmed === '') return trimmed;
205
+ try {
206
+ const url = new URL(trimmed);
207
+ const defaultPort = url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : null;
208
+ // `url.host` keeps a non-default port; drop the scheme's default so
209
+ // https://store.example.com and https://store.example.com:443 match.
210
+ const host = defaultPort && url.port === defaultPort ? url.hostname : url.host;
211
+ const path = url.pathname.replace(/\/+$/, '');
212
+ return `${url.protocol}//${host}${path}`;
213
+ } catch {
214
+ return trimmed.replace(/\/+$/, '');
215
+ }
216
+ }
217
+
218
+ /** Audience comparison that tolerates URL-normalization differences. */
219
+ function audienceMatches(claimed, expected) {
220
+ return normalizeAudience(claimed) === normalizeAudience(expected);
221
+ }
222
+
223
+ // --- Canonical cart binding (Cart Mandate support) ---
224
+ //
225
+ // AP2's Cart Mandate proves the human approved an EXACT cart, not just a
226
+ // budget. The proof only means something if the merchant can recompute the
227
+ // same fingerprint from its own session cart and compare it against the
228
+ // signed claim - so both sides need one deterministic serialization.
229
+ //
230
+ // Rules (must stay byte-identical to the agent-sdk WebCrypto port):
231
+ // * payload = { v: 1, currency, items: [{ id, s, q, u }] }
232
+ // - id: product identifier string; s: size/variant string ('' when none)
233
+ // - q: positive integer quantity; u: unit price in HUNDREDTHS of the
234
+ // major unit. Hundredths is fixed by the wire format, deliberately
235
+ // independent of the currency's real ISO 4217 minor unit, so every
236
+ // port computes the same bytes without a currency table.
237
+ // - items sorted by (id, s); totalCents derived as sum(q*u)
238
+ // * stableStringify: object keys sorted recursively, arrays keep order
239
+ // * cartHash = 'ac1_' + base64url(sha256(stableStringify(payload)))
240
+ // Only strings and integers appear in the payload, so JSON.stringify output is
241
+ // identical across Node/V8 and browser engines (no float formatting).
242
+
243
+ /** Bounds per-item quantity so a cart cannot be used to force an overflow. */
244
+ const MAX_ITEM_QUANTITY = 1_000_000;
245
+ /** Bounds cart size; a cart hash over 10k lines is not a real basket. */
246
+ const MAX_CART_ITEMS = 1000;
247
+
248
+ /**
249
+ * Deterministic JSON serialization: object keys sorted lexicographically at
250
+ * every depth, arrays kept in order. Throws on non-finite numbers and on
251
+ * structures nested deeply enough to risk a stack overflow.
252
+ *
253
+ * @param {unknown} value
254
+ * @param {number} [depth]
255
+ * @returns {string}
256
+ */
257
+ export function stableStringify(value, depth = 0) {
258
+ if (depth > 64) throw new Error('stableStringify: input is nested too deeply');
259
+ if (value === null || typeof value !== 'object') {
260
+ if (typeof value === 'number' && !Number.isFinite(value)) {
261
+ throw new Error('stableStringify cannot serialize non-finite numbers');
262
+ }
263
+ if (typeof value === 'bigint') throw new Error('stableStringify cannot serialize BigInt');
264
+ return JSON.stringify(value);
265
+ }
266
+ if (Array.isArray(value)) {
267
+ return `[${value.map((entry) => (entry === undefined ? 'null' : stableStringify(entry, depth + 1))).join(',')}]`;
268
+ }
269
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
270
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key], depth + 1)}`).join(',')}}`;
271
+ }
272
+
273
+ /**
274
+ * Normalizes a cart into the canonical integer-only payload and returns it
275
+ * together with the derived cartHash. Both the agent side (agent-sdk port)
276
+ * and merchants (via this function) must produce identical hashes for the
277
+ * same logical cart or the Cart Mandate binding rejects checkout.
278
+ *
279
+ * @param {object} input
280
+ * @param {string} [input.currency='USD']
281
+ * @param {Array<{id: unknown, size?: unknown, quantity?: unknown, unitPrice?: unknown, price?: unknown}>} input.items
282
+ * @returns {{ payload: object, itemCount: number, totalCents: number, cartHash: string }}
283
+ */
284
+ export function canonicalCartHash(input = {}) {
285
+ const currency = normalizeCurrencyCode(input.currency) || 'USD';
286
+ const rawItems = Array.isArray(input.items) ? input.items : [];
287
+ if (rawItems.length > MAX_CART_ITEMS) {
288
+ throw new Error(`canonicalCartHash: a cart may contain at most ${MAX_CART_ITEMS} items`);
289
+ }
290
+
291
+ const items = rawItems.map((item) => {
292
+ const id = String(item?.id ?? '').trim();
293
+ const size = String(item?.size ?? '').trim();
294
+ if (!id) throw new Error('canonicalCartHash: every cart item requires an `id`');
295
+
296
+ // Quantities are counts, not money: an integer parse is correct here.
297
+ const quantityAmount = parseAmount(item?.quantity, { allowZero: false });
298
+ const quantity = quantityAmount === null ? NaN : Math.round(Number(quantityAmount.decimal));
299
+ if (!Number.isInteger(quantity) || quantity <= 0 || quantity > MAX_ITEM_QUANTITY) {
300
+ throw new Error(`canonicalCartHash: item "${id}" has an invalid quantity`);
301
+ }
302
+
303
+ // Exact decimal -> hundredths. `Math.round(price * 100)` silently lost a
304
+ // cent for any price whose double sits just below a half-cent boundary.
305
+ const unitSource = item?.unitPrice ?? item?.price ?? 0;
306
+ const unitCents = toHundredths(unitSource);
307
+ if (unitCents === null || unitCents < 0) {
308
+ throw new Error(`canonicalCartHash: item "${id}" has an invalid unit price`);
309
+ }
310
+ return { id, s: size, q: quantity, u: unitCents };
311
+ }).sort((a, b) => (a.id === b.id ? (a.s < b.s ? -1 : a.s > b.s ? 1 : 0) : a.id < b.id ? -1 : 1));
312
+
313
+ if (!items.length) throw new Error('canonicalCartHash: the cart has no items');
314
+
315
+ const payload = { v: 1, currency, items };
316
+ // Exact summation: the old `reduce` over doubles silently lost precision
317
+ // past 2^53, which would let a crafted cart produce a total the merchant
318
+ // could not reproduce.
319
+ const totalCents = sumLineItems(items.map((item) => ({ quantity: item.q, unitHundredths: item.u })));
320
+ if (totalCents === null) throw new Error('canonicalCartHash: the cart total is not exactly representable');
321
+ const itemCount = sumLineItems(items.map((item) => ({ quantity: item.q, unitHundredths: 1 })));
322
+
323
+ const digest = crypto.createHash('sha256').update(stableStringify(payload)).digest('base64url');
324
+ return { payload, itemCount, totalCents, cartHash: `ac1_${digest}` };
325
+ }
326
+
327
+ // --- Signature verification helpers ---
328
+
329
+ /**
330
+ * JWS algorithms, each pinned to the exact key material it may be used with.
331
+ *
332
+ * The previous implementation kept a flat allow-list (`EXPLICIT_KEY_ALLOWED_ALGS`)
333
+ * and let a mismatch fall through to the crypto call. Membership in a set is
334
+ * not a binding; this table is.
335
+ */
336
+ const JWS_ALGORITHMS = Object.freeze({
337
+ EdDSA: { keyType: 'ed25519', hash: null },
338
+ ES256: { keyType: 'ec', curve: 'prime256v1', hash: 'sha256', dsaEncoding: 'ieee-p1363', sigLength: 64 },
339
+ ES384: { keyType: 'ec', curve: 'secp384r1', hash: 'sha384', dsaEncoding: 'ieee-p1363', sigLength: 96 },
340
+ ES512: { keyType: 'ec', curve: 'secp521r1', hash: 'sha512', dsaEncoding: 'ieee-p1363', sigLength: 132 },
341
+ RS256: { keyType: 'rsa', hash: 'sha256', rsaPadding: 'pkcs1' },
342
+ RS384: { keyType: 'rsa', hash: 'sha384', rsaPadding: 'pkcs1' },
343
+ RS512: { keyType: 'rsa', hash: 'sha512', rsaPadding: 'pkcs1' },
344
+ PS256: { keyType: 'rsa', hash: 'sha256', rsaPadding: 'pss' },
345
+ PS384: { keyType: 'rsa', hash: 'sha384', rsaPadding: 'pss' },
346
+ PS512: { keyType: 'rsa', hash: 'sha512', rsaPadding: 'pss' },
347
+ });
348
+
349
+ /** Algorithms accepted when the merchant configured an explicit trusted key. */
350
+ export const SUPPORTED_MANDATE_ALGORITHMS = Object.freeze(Object.keys(JWS_ALGORITHMS));
351
+
352
+ const MIN_RSA_MODULUS_BITS = 2048;
353
+ /** `jti` becomes a replay-cache key; bound its shape before it is used. */
354
+ const JTI_PATTERN = /^[\x21-\x7e]{8,256}$/;
355
+ /** A compact JWS is three base64url segments and nothing else. */
356
+ const COMPACT_JWS_SEGMENT = /^[A-Za-z0-9_-]+$/;
357
+ /** Bounds the whole token so a giant header cannot cost real work. */
358
+ const MAX_MANDATE_TOKEN_LENGTH = 16 * 1024;
359
+ /** Default tolerance for client/server clock drift, in seconds. */
360
+ const DEFAULT_CLOCK_SKEW_SEC = 60;
361
+
362
+ /**
363
+ * Confirms a key is usable with a declared algorithm.
364
+ *
365
+ * @param {crypto.KeyObject} key
366
+ * @param {string} alg
367
+ * @returns {string|null} an error description, or null when compatible
368
+ */
369
+ function keyIncompatibility(key, alg) {
370
+ const spec = JWS_ALGORITHMS[alg];
371
+ if (!spec) return `Unsupported signature algorithm: ${alg}`;
372
+ const keyType = String(key.asymmetricKeyType || '').toLowerCase();
373
+ const details = key.asymmetricKeyDetails || {};
374
+
375
+ if (spec.keyType === 'ed25519') {
376
+ if (keyType !== 'ed25519') return `Algorithm ${alg} requires an Ed25519 key, got ${keyType || 'unknown'}`;
377
+ return null;
378
+ }
379
+ if (spec.keyType === 'ec') {
380
+ if (keyType !== 'ec') return `Algorithm ${alg} requires an EC key, got ${keyType || 'unknown'}`;
381
+ if (details.namedCurve !== spec.curve) {
382
+ return `Algorithm ${alg} requires curve ${spec.curve}, got ${details.namedCurve || 'unknown'}`;
383
+ }
384
+ return null;
385
+ }
386
+ if (spec.keyType === 'rsa') {
387
+ if (keyType !== 'rsa' && keyType !== 'rsa-pss') {
388
+ return `Algorithm ${alg} requires an RSA key, got ${keyType || 'unknown'}`;
389
+ }
390
+ const bits = Number(details.modulusLength || 0);
391
+ if (!(bits >= MIN_RSA_MODULUS_BITS)) {
392
+ return `RSA key is ${bits || 'an unknown number of'} bits; the minimum is ${MIN_RSA_MODULUS_BITS}`;
393
+ }
394
+ return null;
395
+ }
396
+ return `Unsupported signature algorithm: ${alg}`;
397
+ }
398
+
399
+ /**
400
+ * Verifies a compact JWS signature with the algorithm's exact parameters.
401
+ *
402
+ * @param {string} alg
403
+ * @param {crypto.KeyObject} key
404
+ * @param {Buffer} signingInput
405
+ * @param {Buffer} signature
406
+ * @returns {boolean}
407
+ */
408
+ function verifyJwsSignature(alg, key, signingInput, signature) {
409
+ const spec = JWS_ALGORITHMS[alg];
410
+ if (!spec) return false;
411
+
412
+ if (spec.keyType === 'ed25519') {
413
+ if (signature.length !== 64) return false;
414
+ return crypto.verify(null, signingInput, key, signature);
415
+ }
416
+ if (spec.keyType === 'ec') {
417
+ // JOSE ECDSA signatures are raw r||s. Node defaults to DER, which is why
418
+ // every genuine ES* mandate failed before this line existed.
419
+ if (signature.length !== spec.sigLength) return false;
420
+ return crypto.verify(spec.hash, signingInput, { key, dsaEncoding: spec.dsaEncoding }, signature);
421
+ }
422
+ const keyInput = spec.rsaPadding === 'pss'
423
+ ? { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST }
424
+ : { key, padding: crypto.constants.RSA_PKCS1_PADDING };
425
+ return crypto.verify(spec.hash, signingInput, keyInput, signature);
426
+ }
427
+
428
+ /**
429
+ * Builds a rejection result.
430
+ *
431
+ * `disclose` tells the HTTP layer whether `reason` is safe to return to the
432
+ * caller. Actionable policy failures (budget too low, wrong category, replay)
433
+ * are disclosed so a legitimate agent can self-correct; anything that would
434
+ * reveal store configuration or verifier internals is not.
435
+ */
436
+ function rejectMandate(code, reason, { disclose = true, publicReason } = {}) {
437
+ return {
438
+ valid: false,
439
+ protocol: 'AP2',
440
+ verified: false,
441
+ code,
442
+ reason,
443
+ disclose,
444
+ publicReason: publicReason || (disclose ? reason : 'The mandate failed validation.'),
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Projects the decoded mandate payload down to the claims the SDK and
450
+ * merchant handlers actually consume.
451
+ *
452
+ * The verifier used to return `mandates: payload` — the entire
453
+ * attacker-supplied JWT body — which `/ap2/intent` then echoed back in its
454
+ * response. That is an unbounded reflection surface and an easy way to smuggle
455
+ * content through a store's own API.
456
+ *
457
+ * @param {Record<string, unknown>} payload
458
+ * @returns {Record<string, unknown>}
459
+ */
460
+ function projectMandate(payload) {
461
+ const pick = (source, keys) => {
462
+ if (!source || typeof source !== 'object') return undefined;
463
+ const out = {};
464
+ for (const key of keys) {
465
+ if (source[key] !== undefined) out[key] = source[key];
466
+ }
467
+ return Object.keys(out).length > 0 ? out : undefined;
468
+ };
469
+
470
+ const intent = pick(payload.intentMandate, [
471
+ 'maxBudget', 'currency', 'merchantScope', 'allowedCategories', 'expiresAt',
472
+ ]);
473
+ if (intent && Array.isArray(intent.allowedCategories)) {
474
+ intent.allowedCategories = intent.allowedCategories
475
+ .filter((entry) => typeof entry === 'string')
476
+ .slice(0, 64)
477
+ .map((entry) => entry.slice(0, 128));
478
+ }
479
+
480
+ const projected = {
481
+ iss: payload.iss,
482
+ aud: payload.aud,
483
+ sub: typeof payload.sub === 'string' ? payload.sub.slice(0, 256) : payload.sub,
484
+ iat: payload.iat,
485
+ exp: payload.exp,
486
+ jti: payload.jti,
487
+ };
488
+ if (intent) projected.intentMandate = intent;
489
+ const cart = pick(payload.cartMandate, ['cartHash', 'itemCount', 'totalCents', 'currency']);
490
+ if (cart) projected.cartMandate = cart;
491
+ const payment = pick(payload.paymentMandate, ['paymentMethod', 'currency', 'agentInitiated', 'maxBudget']);
492
+ if (payment) projected.paymentMandate = payment;
493
+ return projected;
494
+ }
495
+
496
+ /**
497
+ * Verifies an AP2 mandate SD-JWT presented in an incoming request.
498
+ *
499
+ * Returns synchronously when no shared replay store is configured, and a
500
+ * Promise when one is — callers must treat the result as maybe-async
501
+ * (`await` works for both).
502
+ *
503
+ * @param {Record<string, string>} [headers]
504
+ * @param {Record<string, unknown>} [body]
505
+ * @param {object} [options]
506
+ * @param {crypto.KeyObject|Buffer|string|null} [options.trustedPublicKey] - merchant-configured
507
+ * out-of-band-trusted key. When set it is used instead of deriving one from
508
+ * a did:key issuer, and the broader algorithm set is permitted.
509
+ * @param {string} [options.expectedAudience] - when set, `aud` must match
510
+ * @param {number} [options.maxMandateLifetimeSec=3600] - maximum allowed `exp - iat`
511
+ * @param {boolean} [options.requireJti=true]
512
+ * @param {string[]} [options.requestedCategories] - when set and the mandate
513
+ * declares `intentMandate.allowedCategories`, the two must overlap
514
+ * @param {boolean} [options.allowSelfCertifying=true] - when false, did:key
515
+ * mandates (which anyone can mint for any budget) are rejected in favor of
516
+ * the merchant-trusted key
517
+ * @param {string} [options.expectedCurrency] - pins the currency budgets are
518
+ * denominated in
519
+ * @param {boolean} [options.requireCurrency=true] - reject a mandate that
520
+ * declares a `maxBudget` with no `currency`. A budget without a unit is
521
+ * forgeable by relabeling, and EVERY currency check was previously
522
+ * conditional on the mandate declaring one.
523
+ * @param {number} [options.clockSkewSec=60] - tolerance for `iat`/`exp`
524
+ * @param {boolean} [options.consumeJti=true] - when false the mandate is
525
+ * verified WITHOUT consuming its single-use jti (used for mandates riding
526
+ * along on non-AP2 routes so they do not burn themselves)
527
+ * @param {string|null} [options.expectedCartHash=null] - the merchant-computed
528
+ * canonical cart hash. When the mandate carries a `cartMandate.cartHash` the
529
+ * two must match: the human signed THAT exact cart, not this one.
530
+ * @param {number} [options.nowMs] - injectable clock for tests
531
+ * @returns {object|Promise<object>}
532
+ */
533
+ export function verifyAp2Mandate(headers = {}, body = {}, options = {}) {
534
+ const {
535
+ trustedPublicKey = null,
536
+ expectedAudience = null,
537
+ maxMandateLifetimeSec = 3600,
538
+ requireJti = true,
539
+ requestedCategories = null,
540
+ allowSelfCertifying = true,
541
+ expectedCurrency = null,
542
+ requireCurrency = true,
543
+ clockSkewSec = DEFAULT_CLOCK_SKEW_SEC,
544
+ consumeJti = true,
545
+ expectedCartHash = null,
546
+ nowMs = Date.now(),
547
+ } = options;
548
+
549
+ const mandateHeader = headers['x-ap2-mandate'] || headers['authorization'];
550
+ if (!mandateHeader) {
551
+ // AP2 simply isn't in use for this request - not a security decision.
552
+ return { valid: true, protocol: 'AP2', verified: false, note: 'No AP2 Mandates attached' };
553
+ }
554
+
555
+ // --- Envelope parsing (strict) ---
556
+ const token = String(mandateHeader).replace(/^Bearer\s+/i, '').trim();
557
+ if (token.length === 0 || token.length > MAX_MANDATE_TOKEN_LENGTH) {
558
+ return rejectMandate('mandate_malformed', 'Invalid AP2 Mandate SD-JWT format');
559
+ }
560
+ if (token.includes('~')) {
561
+ // SD-JWT selective disclosure is not implemented. Silently ignoring the
562
+ // disclosure segments (as `split('.')` did) means verifying a subset of
563
+ // what the holder presented.
564
+ return rejectMandate(
565
+ 'sd_jwt_disclosures_unsupported',
566
+ 'AP2 mandates with SD-JWT selective-disclosure segments are not supported by this verifier',
567
+ );
568
+ }
569
+
570
+ const parts = token.split('.');
571
+ // Exactly three segments. `>= 3` accepted a 5-segment JWE shape and treated
572
+ // its third segment as the signature over the first two.
573
+ if (parts.length !== 3 || !parts.every((part) => COMPACT_JWS_SEGMENT.test(part))) {
574
+ return rejectMandate('mandate_malformed', 'Invalid AP2 Mandate SD-JWT format');
575
+ }
576
+
577
+ let header;
578
+ let payload;
579
+ try {
580
+ header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
581
+ payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
582
+ } catch {
583
+ return rejectMandate('mandate_malformed', 'Invalid AP2 Mandate SD-JWT format');
584
+ }
585
+ if (!header || typeof header !== 'object' || Array.isArray(header)) {
586
+ return rejectMandate('mandate_malformed', 'Invalid AP2 Mandate SD-JWT format');
587
+ }
588
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
589
+ return rejectMandate('mandate_malformed', 'Invalid AP2 Mandate SD-JWT format');
590
+ }
591
+ const signatureB64 = parts[2];
592
+
593
+ // --- JOSE header sanity ---
594
+ if (header.typ !== undefined && !['jwt', 'sd-jwt'].includes(String(header.typ).toLowerCase())) {
595
+ return rejectMandate('typ_unsupported', `Unsupported mandate typ: ${String(header.typ).slice(0, 64)}`);
596
+ }
597
+ if (header.crit !== undefined) {
598
+ // `crit` means "you MUST understand these extensions". We understand none.
599
+ return rejectMandate(
600
+ 'crit_unsupported',
601
+ 'AP2 mandate declares critical header extensions that this verifier does not implement',
602
+ );
603
+ }
604
+ if (typeof header.alg !== 'string' || header.alg.length === 0) {
605
+ return rejectMandate('alg_missing', 'Missing alg in mandate header');
606
+ }
607
+ // `alg: none` and friends: only registered asymmetric algorithms exist here,
608
+ // so an unknown value can never reach the crypto call.
609
+ if (!JWS_ALGORITHMS[header.alg]) {
610
+ return rejectMandate('alg_unsupported', `Unsupported signature algorithm: ${String(header.alg).slice(0, 32)}`);
611
+ }
612
+
613
+ // --- Required claims present ---
614
+ if (!payload.iss || typeof payload.iss !== 'string') return rejectMandate('claim_missing', 'Missing issuer');
615
+ if (!payload.aud || typeof payload.aud !== 'string') return rejectMandate('claim_missing', 'Missing audience');
616
+ if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat)) {
617
+ return rejectMandate('claim_missing', 'Missing iat');
618
+ }
619
+ if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
620
+ return rejectMandate('claim_missing', 'Missing exp');
621
+ }
622
+ if (requireJti && !payload.jti) {
623
+ return rejectMandate('claim_missing', 'Missing jti (required for replay protection)');
624
+ }
625
+ if (payload.jti !== undefined && (typeof payload.jti !== 'string' || !JTI_PATTERN.test(payload.jti))) {
626
+ // The jti becomes a replay-cache key; an unbounded value became an
627
+ // unbounded Redis key.
628
+ return rejectMandate('jti_invalid', 'Mandate jti must be 8-256 printable ASCII characters');
629
+ }
630
+ if (payload.nbf !== undefined) {
631
+ if (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf)) {
632
+ return rejectMandate('claim_missing', 'Invalid nbf');
633
+ }
634
+ }
635
+
636
+ // --- Temporal validity (with clock-skew tolerance) ---
637
+ const now = Math.floor(nowMs / 1000);
638
+ const skew = Number.isFinite(clockSkewSec) && clockSkewSec >= 0 ? Math.floor(clockSkewSec) : 0;
639
+ if (payload.exp < now - skew) return rejectMandate('expired', 'Token expired');
640
+ if (payload.iat > now + skew) return rejectMandate('future', 'Token issued in the future');
641
+ if (payload.nbf !== undefined && payload.nbf > now + skew) {
642
+ return rejectMandate('not_yet_valid', 'Token is not yet valid');
643
+ }
644
+ if (payload.exp - payload.iat > maxMandateLifetimeSec) {
645
+ return rejectMandate(
646
+ 'lifetime_exceeded',
647
+ `Mandate lifetime exceeds maximum allowed (${maxMandateLifetimeSec}s)`,
648
+ );
649
+ }
650
+
651
+ // --- Resolve verification key + algorithm policy ---
652
+ let publicKey = null;
653
+ let usingSelfCertifyingKey = false;
654
+
655
+ const isDidKeyIssuer = payload.iss.startsWith('did:key:');
656
+ if (!allowSelfCertifying && isDidKeyIssuer) {
657
+ return rejectMandate(
658
+ 'self_certifying_disabled',
659
+ 'Self-certifying did:key mandates are disabled for this store; a merchant-trusted public key is required',
660
+ );
661
+ }
662
+
663
+ if (trustedPublicKey) {
664
+ try {
665
+ publicKey = trustedPublicKey instanceof crypto.KeyObject
666
+ ? trustedPublicKey
667
+ : crypto.createPublicKey(trustedPublicKey);
668
+ } catch {
669
+ return rejectMandate('trusted_key_invalid', 'Configured trusted public key is invalid', { disclose: false });
670
+ }
671
+ // Pin the declared algorithm to the actual key. An allow-list check alone
672
+ // let an attacker shop algorithms against an unrelated key.
673
+ const incompatibility = keyIncompatibility(publicKey, header.alg);
674
+ if (incompatibility) {
675
+ return rejectMandate('alg_key_mismatch', incompatibility, {
676
+ disclose: false,
677
+ publicReason: 'The mandate signature algorithm is not accepted by this store.',
678
+ });
679
+ }
680
+ } else if (isDidKeyIssuer) {
681
+ usingSelfCertifyingKey = true;
682
+ if (header.alg !== 'EdDSA') {
683
+ return rejectMandate('didkey_alg', 'did:key issuers require the EdDSA algorithm');
684
+ }
685
+ publicKey = ed25519PublicKeyFromDidKey(payload.iss);
686
+ if (!publicKey) {
687
+ return rejectMandate('didkey_invalid', 'Issuer is not a valid Ed25519 did:key identifier');
688
+ }
689
+ } else {
690
+ // No merchant-trusted key configured, and the issuer isn't a
691
+ // self-certifying did:key we can derive a key from - there is no way to
692
+ // verify this mandate, so it must be rejected rather than passed through
693
+ // as "valid but unverified".
694
+ return rejectMandate(
695
+ 'unverifiable',
696
+ 'Cannot verify mandate: issuer is not a did:key and no trusted public key is configured',
697
+ );
698
+ }
699
+
700
+ // --- Signature verification (mandatory) ---
701
+ try {
702
+ const signingInput = Buffer.from(`${parts[0]}.${parts[1]}`, 'utf8');
703
+ const signature = Buffer.from(signatureB64, 'base64url');
704
+ if (!verifyJwsSignature(header.alg, publicKey, signingInput, signature)) {
705
+ return rejectMandate('signature_invalid', 'Invalid signature');
706
+ }
707
+ } catch (err) {
708
+ // Raw crypto error text used to be echoed to the caller verbatim.
709
+ return rejectMandate('signature_error', `Signature verification failed: ${err.message}`, {
710
+ disclose: false,
711
+ publicReason: 'Invalid signature',
712
+ });
713
+ }
714
+
715
+ // --- Audience binding ---
716
+ if (expectedAudience && !audienceMatches(payload.aud, expectedAudience)) {
717
+ return rejectMandate(
718
+ 'audience_mismatch',
719
+ `Mandate audience "${payload.aud}" does not match this store ("${expectedAudience}")`,
720
+ { disclose: false, publicReason: 'This mandate was issued for a different store.' },
721
+ );
722
+ }
723
+
724
+ // --- merchantScope binding (if the mandate declares one, it must match) ---
725
+ const merchantScope = payload.intentMandate?.merchantScope;
726
+ if (expectedAudience && merchantScope && !audienceMatches(merchantScope, expectedAudience)) {
727
+ return rejectMandate(
728
+ 'merchant_scope_mismatch',
729
+ `Mandate merchantScope "${merchantScope}" does not match this store ("${expectedAudience}")`,
730
+ { disclose: false, publicReason: 'This mandate is scoped to a different merchant.' },
731
+ );
732
+ }
733
+
734
+ return finishVerification();
735
+
736
+ /**
737
+ * Everything after signature/audience verification. The jti claim is the
738
+ * LAST step so a rejected attempt (over budget, wrong category, wrong cart)
739
+ * never burns the mandate and the corrected retry still works.
740
+ *
741
+ * Maybe-async: returns a Promise only when a shared replay store makes the
742
+ * claim asynchronous.
743
+ */
744
+ function finishVerification() {
745
+ // --- Cart Mandate binding ---
746
+ // A signed cartMandate.cartHash means the human approved one exact cart.
747
+ // When the merchant can recompute its own canonical hash, a mismatch is a
748
+ // hard reject: swapping cart contents under a signed approval must fail.
749
+ const rawClaimedCartHash = payload.cartMandate?.cartHash;
750
+ const claimedCartHash = typeof rawClaimedCartHash === 'string' ? rawClaimedCartHash.trim() : null;
751
+ let cartBinding = claimedCartHash ? 'unverified' : 'unbound';
752
+ if (claimedCartHash && expectedCartHash) {
753
+ const a = Buffer.from(claimedCartHash, 'utf8');
754
+ const b = Buffer.from(String(expectedCartHash), 'utf8');
755
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
756
+ return rejectMandate(
757
+ 'cart_binding_mismatch',
758
+ 'Cart binding mismatch: the mandate signs a different cart than the one in this session (Cart Mandate verification failed)',
759
+ );
760
+ }
761
+ cartBinding = 'verified';
762
+ }
763
+
764
+ // --- Category enforcement (only when both sides supply one) ---
765
+ const allowedCategories = payload.intentMandate?.allowedCategories;
766
+ if (Array.isArray(allowedCategories) && Array.isArray(requestedCategories) && requestedCategories.length > 0) {
767
+ const allowed = new Set(allowedCategories.filter((entry) => typeof entry === 'string'));
768
+ const overlaps = requestedCategories.some((category) => allowed.has(category));
769
+ if (!overlaps) {
770
+ return rejectMandate(
771
+ 'category_denied',
772
+ `Requested categories [${requestedCategories.join(', ')}] are not within the mandate's allowedCategories [${allowedCategories.join(', ')}]`,
773
+ );
774
+ }
775
+ }
776
+
777
+ // --- Currency binding ---
778
+ // A budget number without a currency is unitless and forgeable by
779
+ // relabeling: a "100" cap minted in a low-value currency must not
780
+ // authorize "100" in the merchant's currency. Every currency check used to
781
+ // be conditional on the mandate declaring one, so omitting `currency`
782
+ // bypassed all of them.
783
+ const rawMandateCurrency = payload.intentMandate?.currency ?? payload.paymentMandate?.currency;
784
+ const mandateCurrency = normalizeCurrencyCode(rawMandateCurrency);
785
+ const rawMaxBudget = payload.intentMandate?.maxBudget ?? payload.paymentMandate?.maxBudget;
786
+ const declaresBudget = rawMaxBudget !== undefined && rawMaxBudget !== null;
787
+
788
+ if (rawMandateCurrency !== undefined && rawMandateCurrency !== null && !mandateCurrency) {
789
+ return rejectMandate(
790
+ 'currency_invalid',
791
+ `Mandate currency "${String(rawMandateCurrency).slice(0, 16)}" is not a valid ISO 4217 code`,
792
+ );
793
+ }
794
+ if (requireCurrency && declaresBudget && !mandateCurrency) {
795
+ return rejectMandate(
796
+ 'currency_missing',
797
+ 'Mandate declares a maxBudget with no currency; an amount without a unit cannot be enforced',
798
+ );
799
+ }
800
+ const expected = normalizeCurrencyCode(expectedCurrency);
801
+ if (expectedCurrency && !expected) {
802
+ return rejectMandate('store_currency_invalid', 'Configured expectedCurrency is not a valid ISO 4217 code', {
803
+ disclose: false,
804
+ });
805
+ }
806
+ if (mandateCurrency && expected && mandateCurrency !== expected) {
807
+ return rejectMandate(
808
+ 'currency_mismatch_store',
809
+ `Mandate currency "${mandateCurrency}" does not match this store's expected currency "${expected}"`,
810
+ { disclose: false, publicReason: 'The mandate was issued for a different currency than this store accepts.' },
811
+ );
812
+ }
813
+ const bodyCurrency = body?.currency === undefined || body?.currency === null
814
+ ? null
815
+ : normalizeCurrencyCode(body.currency);
816
+ if (body?.currency !== undefined && body?.currency !== null && !bodyCurrency) {
817
+ return rejectMandate('order_currency_invalid', 'Order currency is not a valid ISO 4217 code');
818
+ }
819
+ if (mandateCurrency && bodyCurrency && mandateCurrency !== bodyCurrency) {
820
+ return rejectMandate(
821
+ 'currency_mismatch_order',
822
+ `Order currency "${bodyCurrency}" does not match the mandate's currency "${mandateCurrency}"`,
823
+ );
824
+ }
825
+
826
+ // --- Budget enforcement (convenience pre-check) ---
827
+ // The authoritative check runs at the /ap2/checkout call site against the
828
+ // merchant-computed total; this one only lets an obviously-over-budget
829
+ // request fail before any handler runs.
830
+ if (declaresBudget) {
831
+ const budget = parseAmount(rawMaxBudget, { allowZero: true });
832
+ if (budget === null) {
833
+ return rejectMandate(
834
+ 'budget_invalid',
835
+ 'Mandate maxBudget is not a valid non-negative amount',
836
+ );
837
+ }
838
+ if (body?.orderTotal !== undefined && body?.orderTotal !== null) {
839
+ const orderTotal = parseAmount(body.orderTotal, { allowZero: true });
840
+ // `Number('abc')` is NaN and `NaN > budget` is false, so a malformed
841
+ // total used to PASS this check. It now fails.
842
+ if (orderTotal === null) {
843
+ return rejectMandate('order_total_invalid', 'Order total is not a valid non-negative amount');
844
+ }
845
+ if (compareAmounts(orderTotal.decimal, budget.decimal) === 1) {
846
+ return rejectMandate('budget_exceeded', 'Order total exceeds Intent Mandate maxBudget limit');
847
+ }
848
+ }
849
+ }
850
+
851
+ const replayRejection = () => rejectMandate(
852
+ 'replay',
853
+ 'Mandate has already been used (replay detected)',
854
+ );
855
+
856
+ // --- Consume the jti: every validation above passed, so winning the
857
+ // atomic claim is the single successful use of this mandate. ---
858
+ if (payload.jti && consumeJti) {
859
+ const claimed = seenMandateJtis.claim(payload.jti, payload.exp * 1000);
860
+ if (typeof claimed === 'boolean') {
861
+ if (!claimed) return replayRejection();
862
+ } else {
863
+ return claimed.then((won) => (won ? buildSuccess() : replayRejection()));
864
+ }
865
+ }
866
+
867
+ return buildSuccess();
868
+
869
+ function buildSuccess() {
870
+ return {
871
+ valid: true,
872
+ protocol: 'AP2',
873
+ verified: true,
874
+ selfCertifying: usingSelfCertifyingKey,
875
+ cartBinding,
876
+ currency: mandateCurrency,
877
+ maxBudget: declaresBudget ? rawMaxBudget : undefined,
878
+ algorithm: header.alg,
879
+ // A curated projection, not the raw attacker-supplied payload.
880
+ mandates: projectMandate(payload),
881
+ };
882
+ }
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Builds and signs a complete AP2 Intent+Payment Mandate SD-JWT for
888
+ * agent-side tooling (test harnesses, demos, or a real agent's own wallet
889
+ * integration). The issuer is a did:key derived from the signing keypair, so
890
+ * the resulting mandate self-certifies without any prior key registration.
891
+ *
892
+ * @param {object} options
893
+ * @param {string} options.audience - the store base URL this mandate authorizes (required)
894
+ * @param {number|string} options.maxBudget - spending cap enforced on checkout (required, > 0)
895
+ * @param {string} [options.currency='USD']
896
+ * @param {string[]} [options.allowedCategories]
897
+ * @param {string} [options.merchantScope] - defaults to `audience`
898
+ * @param {number} [options.lifetimeSec=3600] - capped at 24 hours
899
+ * @param {string} [options.paymentMethod='tokenized_card']
900
+ * @param {string} [options.subject='user_wallet_delegation']
901
+ * @param {boolean} [options.agentInitiated]
902
+ * @param {{ items: Array<object>, currency?: string }} [options.cart] - embeds a
903
+ * Cart Mandate binding this mandate to one exact cart
904
+ * @param {string} [options.cartHash] - precomputed hash, used when `cart` is absent
905
+ * @param {crypto.KeyObject} [options.privateKey] - reuse an Ed25519 private key
906
+ * @returns {{ token: string, did: string, publicKey: crypto.KeyObject, privateKey: crypto.KeyObject, cartHash: string|null, maxBudget: string, audience: string, expiresAt: number }}
907
+ */
908
+ export function createAp2Mandate(options = {}) {
909
+ const {
910
+ audience,
911
+ maxBudget,
912
+ currency = 'USD',
913
+ allowedCategories,
914
+ merchantScope,
915
+ lifetimeSec = 3600,
916
+ paymentMethod = 'tokenized_card',
917
+ subject = 'user_wallet_delegation',
918
+ agentInitiated,
919
+ cart,
920
+ cartHash: providedCartHash,
921
+ privateKey: providedPrivateKey,
922
+ } = options;
923
+
924
+ if (typeof audience !== 'string' || audience.trim() === '') {
925
+ throw new Error('createAp2Mandate requires an `audience` (the target store base URL)');
926
+ }
927
+ if (maxBudget === undefined) throw new Error('createAp2Mandate requires a `maxBudget`');
928
+
929
+ // A capless or malformed budget is rejected at mint time rather than at
930
+ // checkout, so a broken mandate never reaches a payment gate.
931
+ const budget = parseAmount(maxBudget, { allowZero: false });
932
+ if (budget === null) {
933
+ throw new Error('createAp2Mandate requires a positive numeric `maxBudget`');
934
+ }
935
+ const normalizedCurrency = normalizeCurrencyCode(currency);
936
+ if (!normalizedCurrency) {
937
+ throw new Error(`createAp2Mandate requires a valid ISO 4217 \`currency\`, got "${currency}"`);
938
+ }
939
+ if (!Number.isFinite(lifetimeSec) || lifetimeSec <= 0 || lifetimeSec > 24 * 60 * 60) {
940
+ throw new Error('createAp2Mandate `lifetimeSec` must be between 1 second and 24 hours');
941
+ }
942
+
943
+ // Cart Mandate binding: embed the exact-cart fingerprint when a cart (or a
944
+ // precomputed hash) is supplied.
945
+ let cartHash = typeof providedCartHash === 'string' && providedCartHash.trim() ? providedCartHash.trim() : null;
946
+ let cartBindingMeta = null;
947
+ if (cart) {
948
+ const canonical = canonicalCartHash({ currency: normalizedCurrency, items: cart.items });
949
+ cartHash = canonical.cartHash;
950
+ cartBindingMeta = canonical;
951
+ // A cart whose own total exceeds the cap it is minted with is
952
+ // self-contradictory; catching it here saves a doomed checkout.
953
+ const cartTotalMajor = (canonical.totalCents / 100).toFixed(2);
954
+ if (compareAmounts(cartTotalMajor, budget.decimal) === 1) {
955
+ throw new Error(
956
+ `createAp2Mandate: the cart total ${cartTotalMajor} exceeds the requested maxBudget ${budget.decimal}`,
957
+ );
958
+ }
959
+ }
960
+
961
+ const { privateKey, publicKey } = providedPrivateKey
962
+ ? { privateKey: providedPrivateKey, publicKey: crypto.createPublicKey(providedPrivateKey) }
963
+ : crypto.generateKeyPairSync('ed25519');
964
+
965
+ const did = didKeyFromEd25519PublicKey(publicKey);
966
+ const iat = Math.floor(Date.now() / 1000);
967
+ const exp = iat + Math.floor(lifetimeSec);
968
+
969
+ const header = { alg: 'EdDSA', typ: 'JWT' };
970
+ const payload = {
971
+ iss: did,
972
+ aud: audience,
973
+ sub: subject,
974
+ iat,
975
+ exp,
976
+ jti: crypto.randomUUID(),
977
+ intentMandate: {
978
+ maxBudget,
979
+ currency: normalizedCurrency,
980
+ merchantScope: merchantScope || audience,
981
+ ...(allowedCategories ? { allowedCategories } : {}),
982
+ },
983
+ ...(cartBindingMeta
984
+ ? {
985
+ cartMandate: {
986
+ cartHash,
987
+ itemCount: cartBindingMeta.itemCount,
988
+ totalCents: cartBindingMeta.totalCents,
989
+ currency: normalizedCurrency,
990
+ },
991
+ }
992
+ : cartHash
993
+ ? { cartMandate: { cartHash } }
994
+ : {}),
995
+ paymentMandate: {
996
+ paymentMethod,
997
+ currency: normalizedCurrency,
998
+ ...(agentInitiated !== undefined ? { agentInitiated: Boolean(agentInitiated) } : {}),
999
+ },
1000
+ };
1001
+
1002
+ const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
1003
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
1004
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
1005
+ const signature = crypto.sign(null, Buffer.from(signingInput), privateKey).toString('base64url');
1006
+
1007
+ return {
1008
+ token: `${signingInput}.${signature}`,
1009
+ did,
1010
+ publicKey,
1011
+ privateKey,
1012
+ cartHash,
1013
+ maxBudget: budget.decimal,
1014
+ audience,
1015
+ expiresAt: exp,
1016
+ };
1017
+ }