@agentsbloom/sdk 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +354 -216
- package/index.js +2350 -1993
- package/lib/ap2.js +1017 -537
- package/lib/http-signatures.js +874 -0
- package/lib/money.js +283 -0
- package/lib/outcomes.js +108 -108
- package/lib/protocol.d.ts +229 -0
- package/lib/protocol.js +85 -0
- package/lib/shared-store.js +298 -172
- package/lib/signature-base.js +436 -0
- package/lib/structured-fields.js +398 -0
- package/package.json +16 -6
- package/telemetry.js +77 -57
package/lib/ap2.js
CHANGED
|
@@ -1,537 +1,1017 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import { createReplayCache } from './shared-store.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* The rules enforced here:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let
|
|
79
|
-
for (const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
) {
|
|
267
|
-
return {
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
+
}
|