@atrib/verify 0.3.7 → 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.
@@ -2,6 +2,8 @@
2
2
  import canonicalize from 'canonicalize';
3
3
  import { createPublicKey, verify as verifyEcdsa } from 'node:crypto';
4
4
  import { base64urlDecode, base64urlEncode, sha256 } from '@atrib/mcp';
5
+ import { SDJwtInstance } from '@sd-jwt/core';
6
+ import { SDJwtVcInstance } from '@sd-jwt/sd-jwt-vc';
5
7
  import { createLocalJWKSet, createRemoteJWKSet, customFetch, decodeJwt, decodeProtectedHeader, jwtVerify, } from 'jose';
6
8
  const textEncoder = new TextEncoder();
7
9
  const textDecoder = new TextDecoder();
@@ -14,6 +16,9 @@ function isString(value) {
14
16
  function isNumber(value) {
15
17
  return typeof value === 'number' && Number.isFinite(value);
16
18
  }
19
+ function isSafeInteger(value) {
20
+ return typeof value === 'number' && Number.isSafeInteger(value);
21
+ }
17
22
  function isJsonWebKeySet(value) {
18
23
  return (isRecord(value) && Array.isArray(value['keys']) && value['keys'].every((key) => isRecord(key)));
19
24
  }
@@ -26,18 +31,27 @@ function decodeBase64urlJson(value) {
26
31
  function signatureCheck(status, reason) {
27
32
  return reason === undefined ? { status } : { status, reason };
28
33
  }
29
- function collectDelegatePayloadDigests(payload) {
30
- const digests = new Set();
34
+ function sdJwtConformanceCheck(status, profile, reason) {
35
+ return reason === undefined ? { status, profile } : { status, profile, reason };
36
+ }
37
+ function collectDelegatePayloadDigestList(payload) {
38
+ const digests = [];
31
39
  const delegatePayload = payload['delegate_payload'];
32
40
  if (!Array.isArray(delegatePayload))
33
41
  return digests;
34
42
  for (const item of delegatePayload) {
35
43
  if (isRecord(item) && isString(item['...'])) {
36
- digests.add(item['...']);
44
+ digests.push(item['...']);
37
45
  }
38
46
  }
39
47
  return digests;
40
48
  }
49
+ function collectDelegatePayloadDigests(payload) {
50
+ return new Set(collectDelegatePayloadDigestList(payload));
51
+ }
52
+ function hasDuplicateStrings(values) {
53
+ return new Set(values).size !== values.length;
54
+ }
41
55
  function disclosureValue(decoded) {
42
56
  if (!Array.isArray(decoded))
43
57
  return undefined;
@@ -65,7 +79,10 @@ function parseCredential(input) {
65
79
  const value = disclosureValue(decodeBase64urlJson(encoded));
66
80
  return { encoded, digest: hashAscii(encoded), value };
67
81
  });
68
- const mandateValues = disclosures.flatMap((disclosure) => isRecord(disclosure.value) && isString(disclosure.value['vct']) ? [disclosure.value] : []);
82
+ const mandateDisclosures = disclosures.flatMap((disclosure) => isRecord(disclosure.value) && isString(disclosure.value['vct'])
83
+ ? [{ digest: disclosure.digest, value: disclosure.value }]
84
+ : []);
85
+ const mandateValues = mandateDisclosures.map((mandate) => mandate.value);
69
86
  return {
70
87
  input,
71
88
  jwt,
@@ -74,6 +91,7 @@ function parseCredential(input) {
74
91
  signingInput: `${parts[0]}.${parts[1]}`,
75
92
  signature: base64urlDecode(parts[2]),
76
93
  disclosures,
94
+ mandateDisclosures,
77
95
  mandateValues,
78
96
  };
79
97
  }
@@ -97,22 +115,473 @@ function sameJson(left, right) {
97
115
  const rightCanonical = canonicalJson(right);
98
116
  return leftCanonical !== null && leftCanonical === rightCanonical;
99
117
  }
118
+ function getConstraintObjects(mandate) {
119
+ const constraints = mandate['constraints'];
120
+ if (!Array.isArray(constraints))
121
+ return [];
122
+ return constraints.filter(isRecord);
123
+ }
124
+ function normalizeConstraintType(value) {
125
+ return value.startsWith('mandate.') ? value.slice('mandate.'.length) : value;
126
+ }
127
+ function makeConstraintCheck(constraint, domain, status, reason) {
128
+ const type = isString(constraint['type']) ? constraint['type'] : `${domain}.unknown`;
129
+ return reason === undefined ? { type, domain, status } : { type, domain, status, reason };
130
+ }
131
+ function resolveDisclosureRefs(value, disclosures) {
132
+ if (Array.isArray(value))
133
+ return value.map((item) => resolveDisclosureRefs(item, disclosures));
134
+ if (!isRecord(value))
135
+ return value;
136
+ const digest = value['...'];
137
+ if (isString(digest) && Object.keys(value).length === 1) {
138
+ return disclosures.has(digest)
139
+ ? resolveDisclosureRefs(disclosures.get(digest), disclosures)
140
+ : value;
141
+ }
142
+ const resolved = {};
143
+ for (const [key, nested] of Object.entries(value)) {
144
+ resolved[key] = resolveDisclosureRefs(nested, disclosures);
145
+ }
146
+ return resolved;
147
+ }
148
+ function disclosureMap(parsedCredentials) {
149
+ const disclosures = new Map();
150
+ for (const parsed of parsedCredentials) {
151
+ for (const disclosure of parsed.disclosures) {
152
+ disclosures.set(disclosure.digest, disclosure.value);
153
+ }
154
+ }
155
+ return disclosures;
156
+ }
157
+ function comparableFields(left, right, fields) {
158
+ return fields.filter((field) => isString(left[field]) && isString(right[field]));
159
+ }
160
+ function entityMatches(left, right, fields) {
161
+ if (!isRecord(left) || !isRecord(right))
162
+ return false;
163
+ const fieldsToCompare = comparableFields(left, right, fields);
164
+ return (fieldsToCompare.length > 0 && fieldsToCompare.every((field) => left[field] === right[field]));
165
+ }
166
+ function anyEntityMatches(allowed, actual, fields) {
167
+ if (!Array.isArray(allowed))
168
+ return null;
169
+ if (!isRecord(actual))
170
+ return null;
171
+ const allowedRecords = allowed.filter(isRecord);
172
+ if (allowedRecords.length === 0)
173
+ return null;
174
+ return allowedRecords.some((candidate) => entityMatches(candidate, actual, fields));
175
+ }
176
+ function paymentAmount(target) {
177
+ const amount = target['payment_amount'];
178
+ if (!isRecord(amount) || !isSafeInteger(amount['amount']) || !isString(amount['currency'])) {
179
+ return null;
180
+ }
181
+ return { amount: amount['amount'], currency: amount['currency'] };
182
+ }
183
+ function optionalIntegerBound(value) {
184
+ if (value === undefined)
185
+ return undefined;
186
+ return isSafeInteger(value) ? value : null;
187
+ }
188
+ function compareIsoDate(value, lower, upper) {
189
+ const time = Date.parse(value);
190
+ if (!Number.isFinite(time))
191
+ return null;
192
+ if (isString(lower)) {
193
+ const lowerTime = Date.parse(lower);
194
+ if (!Number.isFinite(lowerTime) || time < lowerTime)
195
+ return false;
196
+ }
197
+ if (isString(upper)) {
198
+ const upperTime = Date.parse(upper);
199
+ if (!Number.isFinite(upperTime) || time > upperTime)
200
+ return false;
201
+ }
202
+ return true;
203
+ }
204
+ function decodeCompactJwtPayload(value) {
205
+ try {
206
+ const payload = decodeJwt(value);
207
+ return isRecord(payload) ? payload : undefined;
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
213
+ function resolveCheckoutPayload(explicitPayload, closedCheckoutMandates) {
214
+ if (isRecord(explicitPayload))
215
+ return explicitPayload;
216
+ for (const mandate of closedCheckoutMandates) {
217
+ const payload = mandate['checkout_payload'];
218
+ if (isRecord(payload))
219
+ return payload;
220
+ const checkoutJwt = mandate['checkout_jwt'];
221
+ if (isString(checkoutJwt)) {
222
+ const decoded = decodeCompactJwtPayload(checkoutJwt);
223
+ if (decoded)
224
+ return decoded;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+ function checkoutLineItemId(value) {
230
+ const product = value['product'];
231
+ if (isRecord(product) && isString(product['id']))
232
+ return product['id'];
233
+ if (isRecord(product) && isString(product['sku']))
234
+ return product['sku'];
235
+ if (isString(value['sku']))
236
+ return value['sku'];
237
+ return isString(value['id']) ? value['id'] : null;
238
+ }
239
+ function checkoutLineItems(checkoutPayload) {
240
+ const lineItems = checkoutPayload['line_items'];
241
+ if (Array.isArray(lineItems))
242
+ return lineItems;
243
+ const cart = checkoutPayload['cart'];
244
+ if (isRecord(cart) && Array.isArray(cart['items']))
245
+ return cart['items'];
246
+ return null;
247
+ }
248
+ function checkoutLineItemQuantities(checkoutPayload) {
249
+ const items = checkoutLineItems(checkoutPayload);
250
+ if (items === null)
251
+ return null;
252
+ const quantities = new Map();
253
+ for (const item of items) {
254
+ if (!isRecord(item))
255
+ return null;
256
+ const id = checkoutLineItemId(item);
257
+ const quantity = item['quantity'];
258
+ if (id === null || !isSafeInteger(quantity) || quantity < 0)
259
+ return null;
260
+ quantities.set(id, (quantities.get(id) ?? 0) + quantity);
261
+ }
262
+ return quantities;
263
+ }
264
+ function maxLineItemFlow(requirements, checkoutQuantities) {
265
+ const source = 'source';
266
+ const sink = 'sink';
267
+ const graph = new Map();
268
+ const addEdge = (from, to, capacity) => {
269
+ if (!graph.has(from))
270
+ graph.set(from, new Map());
271
+ if (!graph.has(to))
272
+ graph.set(to, new Map());
273
+ graph.get(from).set(to, (graph.get(from).get(to) ?? 0) + capacity);
274
+ graph.get(to).set(from, graph.get(to).get(from) ?? 0);
275
+ };
276
+ requirements.forEach((requirement, index) => {
277
+ const reqNode = `req:${index}`;
278
+ addEdge(source, reqNode, requirement.quantity);
279
+ for (const itemId of requirement.acceptableIds) {
280
+ if (checkoutQuantities.has(itemId))
281
+ addEdge(reqNode, `item:${itemId}`, Number.MAX_SAFE_INTEGER);
282
+ }
283
+ });
284
+ for (const [itemId, quantity] of checkoutQuantities) {
285
+ addEdge(`item:${itemId}`, sink, quantity);
286
+ }
287
+ let flow = 0;
288
+ for (;;) {
289
+ const parent = new Map();
290
+ const queue = [source];
291
+ parent.set(source, '');
292
+ for (let i = 0; i < queue.length && !parent.has(sink); i += 1) {
293
+ const node = queue[i];
294
+ for (const [next, capacity] of graph.get(node) ?? []) {
295
+ if (capacity > 0 && !parent.has(next)) {
296
+ parent.set(next, node);
297
+ queue.push(next);
298
+ }
299
+ }
300
+ }
301
+ if (!parent.has(sink))
302
+ return flow;
303
+ let increment = Number.MAX_SAFE_INTEGER;
304
+ for (let node = sink; node !== source; node = parent.get(node)) {
305
+ const prev = parent.get(node);
306
+ increment = Math.min(increment, graph.get(prev).get(node));
307
+ }
308
+ for (let node = sink; node !== source; node = parent.get(node)) {
309
+ const prev = parent.get(node);
310
+ graph.get(prev).set(node, graph.get(prev).get(node) - increment);
311
+ graph.get(node).set(prev, graph.get(node).get(prev) + increment);
312
+ }
313
+ flow += increment;
314
+ }
315
+ }
316
+ function evaluatePaymentConstraint(constraint, closedPaymentMandates, nowSeconds, checkoutPaymentBindingOk, openCheckoutMandateDigests = []) {
317
+ const type = isString(constraint['type']) ? normalizeConstraintType(constraint['type']) : '';
318
+ if (closedPaymentMandates.length === 0) {
319
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'closed_payment_missing');
320
+ }
321
+ if (type === 'payment.amount_range') {
322
+ const min = optionalIntegerBound(constraint['min']);
323
+ const max = optionalIntegerBound(constraint['max']);
324
+ if (min === null || max === null) {
325
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'amount_range');
326
+ }
327
+ for (const mandate of closedPaymentMandates) {
328
+ const amount = paymentAmount(mandate);
329
+ if (amount === null) {
330
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'payment_amount');
331
+ }
332
+ if (isString(constraint['currency']) && constraint['currency'] !== amount.currency) {
333
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'currency');
334
+ }
335
+ if (min !== undefined && amount.amount < min) {
336
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'min');
337
+ }
338
+ if (max !== undefined && amount.amount > max) {
339
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'max');
340
+ }
341
+ }
342
+ return makeConstraintCheck(constraint, 'payment', 'passed');
343
+ }
344
+ if (type === 'payment.allowed_payees') {
345
+ for (const mandate of closedPaymentMandates) {
346
+ const ok = anyEntityMatches(constraint['allowed'], mandate['payee'], [
347
+ 'id',
348
+ 'website',
349
+ 'name',
350
+ ]);
351
+ if (ok === null)
352
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
353
+ if (!ok)
354
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'payee');
355
+ }
356
+ return makeConstraintCheck(constraint, 'payment', 'passed');
357
+ }
358
+ if (type === 'payment.allowed_payment_instruments') {
359
+ for (const mandate of closedPaymentMandates) {
360
+ const ok = anyEntityMatches(constraint['allowed'], mandate['payment_instrument'], [
361
+ 'id',
362
+ 'type',
363
+ 'description',
364
+ ]);
365
+ if (ok === null)
366
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
367
+ if (!ok)
368
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'payment_instrument');
369
+ }
370
+ return makeConstraintCheck(constraint, 'payment', 'passed');
371
+ }
372
+ if (type === 'payment.allowed_pisps') {
373
+ for (const mandate of closedPaymentMandates) {
374
+ const ok = anyEntityMatches(constraint['allowed'], mandate['pisp'], [
375
+ 'domain_name',
376
+ 'brand_name',
377
+ 'legal_name',
378
+ ]);
379
+ if (ok === null)
380
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
381
+ if (!ok)
382
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'pisp');
383
+ }
384
+ return makeConstraintCheck(constraint, 'payment', 'passed');
385
+ }
386
+ if (type === 'payment.reference') {
387
+ const referencedCheckoutDigest = constraint['conditional_transaction_id'];
388
+ if (!isString(referencedCheckoutDigest)) {
389
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'conditional_transaction_id');
390
+ }
391
+ if (openCheckoutMandateDigests.length === 0) {
392
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'open_checkout_digest');
393
+ }
394
+ if (!openCheckoutMandateDigests.includes(referencedCheckoutDigest)) {
395
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'open_checkout_digest');
396
+ }
397
+ if (checkoutPaymentBindingOk === false) {
398
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'checkout_payment_binding');
399
+ }
400
+ if (checkoutPaymentBindingOk === null || checkoutPaymentBindingOk === undefined) {
401
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'checkout_payment_binding');
402
+ }
403
+ return makeConstraintCheck(constraint, 'payment', 'passed');
404
+ }
405
+ if (type === 'payment.execution_date') {
406
+ const immediateExecutionDate = new Date(nowSeconds * 1000).toISOString();
407
+ for (const mandate of closedPaymentMandates) {
408
+ const executionDate = isString(mandate['execution_date'])
409
+ ? mandate['execution_date']
410
+ : immediateExecutionDate;
411
+ const ok = compareIsoDate(executionDate, constraint['not_before'], constraint['not_after']);
412
+ if (ok === null)
413
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'date');
414
+ if (!ok)
415
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'execution_date');
416
+ }
417
+ return makeConstraintCheck(constraint, 'payment', 'passed');
418
+ }
419
+ return makeConstraintCheck(constraint, 'payment', 'unsupported');
420
+ }
421
+ function evaluateCheckoutConstraint(constraint, checkoutPayload, closedPaymentMandates) {
422
+ const type = isString(constraint['type']) ? normalizeConstraintType(constraint['type']) : '';
423
+ if (type === 'checkout.allowed_merchants') {
424
+ const merchant = checkoutPayload?.['merchant'] ??
425
+ closedPaymentMandates.find((mandate) => mandate['payee'] !== undefined)?.['payee'];
426
+ const ok = anyEntityMatches(constraint['allowed'], merchant, ['id', 'website', 'name']);
427
+ if (ok === null)
428
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'merchant');
429
+ if (!ok)
430
+ return makeConstraintCheck(constraint, 'checkout', 'failed', 'merchant');
431
+ return makeConstraintCheck(constraint, 'checkout', 'passed');
432
+ }
433
+ if (type === 'checkout.line_items') {
434
+ if (!checkoutPayload) {
435
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'checkout_payload_missing');
436
+ }
437
+ const checkoutQuantities = checkoutLineItemQuantities(checkoutPayload);
438
+ const items = constraint['items'];
439
+ if (checkoutQuantities === null || !Array.isArray(items)) {
440
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'line_items');
441
+ }
442
+ const requirements = [];
443
+ for (const item of items) {
444
+ if (!isRecord(item)) {
445
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'requirement');
446
+ }
447
+ const quantity = item['quantity'];
448
+ if (!isSafeInteger(quantity) || quantity < 0) {
449
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'requirement');
450
+ }
451
+ const acceptableItems = item['acceptable_items'];
452
+ if (!Array.isArray(acceptableItems)) {
453
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'acceptable_items');
454
+ }
455
+ const acceptableIds = new Set();
456
+ for (const acceptable of acceptableItems) {
457
+ if (isRecord(acceptable) && isString(acceptable['id']))
458
+ acceptableIds.add(acceptable['id']);
459
+ }
460
+ if (acceptableIds.size === 0) {
461
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'acceptable_items');
462
+ }
463
+ requirements.push({ quantity, acceptableIds });
464
+ }
465
+ const requiredQuantity = requirements.reduce((sum, item) => sum + item.quantity, 0);
466
+ const checkoutQuantity = [...checkoutQuantities.values()].reduce((sum, quantity) => sum + quantity, 0);
467
+ const maxFlow = maxLineItemFlow(requirements, checkoutQuantities);
468
+ if (maxFlow !== requiredQuantity || requiredQuantity !== checkoutQuantity) {
469
+ return makeConstraintCheck(constraint, 'checkout', 'failed', 'line_items');
470
+ }
471
+ return makeConstraintCheck(constraint, 'checkout', 'passed');
472
+ }
473
+ return makeConstraintCheck(constraint, 'checkout', 'unsupported');
474
+ }
475
+ function summarizeConstraintChecks(checks) {
476
+ if (checks.length === 0)
477
+ return 'not_applicable';
478
+ if (checks.some((check) => check.status === 'failed'))
479
+ return 'failed';
480
+ if (checks.some((check) => check.status === 'unresolved' || check.status === 'unsupported')) {
481
+ return 'unresolved';
482
+ }
483
+ return 'passed';
484
+ }
485
+ export function evaluateAp2ViConstraints(input, disclosures = new Map()) {
486
+ const openCheckoutMandates = (input.openCheckoutMandates ?? [])
487
+ .filter(isRecord)
488
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
489
+ .filter(isRecord);
490
+ const closedCheckoutMandates = (input.closedCheckoutMandates ?? [])
491
+ .filter(isRecord)
492
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
493
+ .filter(isRecord);
494
+ const openPaymentMandates = (input.openPaymentMandates ?? [])
495
+ .filter(isRecord)
496
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
497
+ .filter(isRecord);
498
+ const closedPaymentMandates = (input.closedPaymentMandates ?? [])
499
+ .filter(isRecord)
500
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
501
+ .filter(isRecord);
502
+ const resolvedCheckoutPayload = resolveDisclosureRefs(input.checkoutPayload, disclosures);
503
+ const checkoutPayload = resolveCheckoutPayload(resolvedCheckoutPayload, closedCheckoutMandates);
504
+ const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
505
+ const checkoutPaymentBindingOk = input.checkoutPaymentBindingOk;
506
+ const openCheckoutMandateDigests = input.openCheckoutMandateDigests ?? [];
507
+ const checks = [];
508
+ for (const mandate of openCheckoutMandates) {
509
+ for (const constraint of getConstraintObjects(mandate)) {
510
+ const resolved = resolveDisclosureRefs(constraint, disclosures);
511
+ checks.push(evaluateCheckoutConstraint(isRecord(resolved) ? resolved : constraint, checkoutPayload, closedPaymentMandates));
512
+ }
513
+ }
514
+ for (const mandate of openPaymentMandates) {
515
+ for (const constraint of getConstraintObjects(mandate)) {
516
+ const resolved = resolveDisclosureRefs(constraint, disclosures);
517
+ checks.push(evaluatePaymentConstraint(isRecord(resolved) ? resolved : constraint, closedPaymentMandates, nowSeconds, checkoutPaymentBindingOk, openCheckoutMandateDigests));
518
+ }
519
+ }
520
+ return { status: summarizeConstraintChecks(checks), checks };
521
+ }
100
522
  function normalizeJwks(value) {
101
523
  const keys = Array.isArray(value) ? value : value.keys;
524
+ if (keys.length === 0)
525
+ throw new Error('jwks_empty');
526
+ const seenKids = new Set();
527
+ for (const key of keys)
528
+ validateReceiptJwk(key, seenKids);
102
529
  return { keys: keys };
103
530
  }
104
- function verifyJwtSignature(parsed, publicJwk) {
531
+ function verifyEs256Signature(data, signature, publicJwk) {
105
532
  const key = createPublicKey({ key: publicJwk, format: 'jwk' });
106
- return verifyEcdsa('sha256', Buffer.from(parsed.signingInput, 'ascii'), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(parsed.signature));
533
+ return verifyEcdsa('sha256', Buffer.from(data, 'ascii'), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature));
534
+ }
535
+ function verifyJwtSignature(parsed, publicJwk) {
536
+ return verifyEs256Signature(parsed.signingInput, parsed.signature, publicJwk);
107
537
  }
108
538
  function findIssuerKey(kid, keys) {
109
539
  if (kid === null)
110
540
  return keys[0];
111
541
  return keys.find((key) => getKid(key) === kid);
112
542
  }
543
+ function validateReceiptJwk(key, seenKids) {
544
+ const record = key;
545
+ const kid = getKid(record);
546
+ if (kid !== null) {
547
+ if (seenKids.has(kid))
548
+ throw new Error('jwks_duplicate_kid');
549
+ seenKids.add(kid);
550
+ }
551
+ if (record['kty'] !== 'EC' || record['crv'] !== 'P-256') {
552
+ throw new Error('jwks_key_unsupported');
553
+ }
554
+ const alg = record['alg'];
555
+ if (alg !== undefined && alg !== 'ES256') {
556
+ throw new Error('jwks_key_unsupported');
557
+ }
558
+ const use = record['use'];
559
+ if (use !== undefined && use !== 'sig') {
560
+ throw new Error('jwks_key_unsupported');
561
+ }
562
+ const keyOps = record['key_ops'];
563
+ if (keyOps !== undefined &&
564
+ (!Array.isArray(keyOps) || !keyOps.every(isString) || !keyOps.includes('verify'))) {
565
+ throw new Error('jwks_key_unsupported');
566
+ }
567
+ }
113
568
  function receiptJwtError(kind, code) {
114
569
  return `ap2_${kind}_receipt_jwt_${code}`;
115
570
  }
571
+ function normalizeReceiptJwtErrorCode(code) {
572
+ if (code === 'fetch_unavailable' ||
573
+ code === 'metadata_fetch_failed' ||
574
+ code === 'metadata_invalid' ||
575
+ code === 'metadata_jwks_missing' ||
576
+ code === 'key_source_missing' ||
577
+ code === 'jwks_empty' ||
578
+ code === 'jwks_duplicate_kid' ||
579
+ code === 'jwks_key_unsupported' ||
580
+ code === 'iat_in_future') {
581
+ return code === 'fetch_unavailable' ? 'metadata_fetch_unavailable' : code;
582
+ }
583
+ return 'invalid';
584
+ }
116
585
  function addReceiptJwtFailure(verification, kind, code, policy) {
117
586
  const error = receiptJwtError(kind, code);
118
587
  verification.check.error = error;
@@ -189,6 +658,23 @@ function disclosureDigestsOk(parsed) {
189
658
  const delegateDigests = collectDelegatePayloadDigests(parsed.payload);
190
659
  return parsed.disclosures.every((disclosure) => sdDigests.has(disclosure.digest) || delegateDigests.has(disclosure.digest));
191
660
  }
661
+ function credentialStructuralErrors(parsed) {
662
+ const errors = [];
663
+ if (hasDuplicateStrings(parsed.disclosures.map((disclosure) => disclosure.digest))) {
664
+ errors.push('vi_disclosure_duplicate');
665
+ }
666
+ const sdAlg = parsed.payload['_sd_alg'];
667
+ if (sdAlg !== undefined && sdAlg !== 'sha-256') {
668
+ errors.push('vi_sd_alg_unsupported');
669
+ }
670
+ const sd = parsed.payload['_sd'];
671
+ const sdDigests = Array.isArray(sd) ? sd.filter(isString) : [];
672
+ const delegateDigests = collectDelegatePayloadDigestList(parsed.payload);
673
+ if (hasDuplicateStrings(sdDigests) || hasDuplicateStrings(delegateDigests)) {
674
+ errors.push('vi_disclosure_digest_duplicate');
675
+ }
676
+ return errors;
677
+ }
192
678
  function initialCredentialCheck(parsed) {
193
679
  const alg = isString(parsed.header['alg']) ? parsed.header['alg'] : null;
194
680
  const typ = isString(parsed.header['typ']) ? parsed.header['typ'] : null;
@@ -199,6 +685,7 @@ function initialCredentialCheck(parsed) {
199
685
  typ,
200
686
  kid,
201
687
  signature: signatureCheck('not_checked'),
688
+ sdJwtConformance: sdJwtConformanceCheck('not_checked', null, 'async_required'),
202
689
  sdHashOk: null,
203
690
  disclosuresOk: disclosureDigestsOk(parsed),
204
691
  mandateVcts: parsed.mandateValues.map((mandate) => String(mandate['vct'])),
@@ -264,6 +751,14 @@ async function verifyReceiptJwt(kind, receiptJwt, issuers, policy, nowSeconds, c
264
751
  addReceiptJwtFailure(verification, kind, 'alg_unsupported', policy);
265
752
  return verification;
266
753
  }
754
+ if (header.crit !== undefined) {
755
+ addReceiptJwtFailure(verification, kind, 'crit_unsupported', policy);
756
+ return verification;
757
+ }
758
+ if (!isString(header.kid) || header.kid.length === 0) {
759
+ addReceiptJwtFailure(verification, kind, 'kid_missing', policy);
760
+ return verification;
761
+ }
267
762
  const issuerConfig = findReceiptJwtIssuer(issuer, issuers);
268
763
  if (!issuerConfig) {
269
764
  addReceiptJwtFailure(verification, kind, 'issuer_untrusted', policy);
@@ -281,13 +776,18 @@ async function verifyReceiptJwt(kind, receiptJwt, issuers, policy, nowSeconds, c
281
776
  if (issuerConfig.issuer !== undefined)
282
777
  verifyOptions.issuer = issuerConfig.issuer;
283
778
  const verified = await jwtVerify(receiptJwt, key.getKey, verifyOptions);
779
+ const iat = verified.payload.iat;
780
+ if (isNumber(iat) && nowSeconds + clockSkewSeconds < iat) {
781
+ addReceiptJwtFailure(verification, kind, 'iat_in_future', policy);
782
+ return verification;
783
+ }
284
784
  verification.check.verified = true;
285
785
  verification.payload = verified.payload;
286
786
  return verification;
287
787
  }
288
788
  catch (error) {
289
789
  const code = error instanceof Error && error.message ? error.message : 'invalid';
290
- addReceiptJwtFailure(verification, kind, code === 'fetch_unavailable' ? 'metadata_fetch_unavailable' : 'invalid', policy);
790
+ addReceiptJwtFailure(verification, kind, normalizeReceiptJwtErrorCode(code), policy);
291
791
  if (code !== 'invalid' && code !== 'fetch_unavailable') {
292
792
  verification.warnings.push(`${receiptJwtError(kind, 'detail')}:${code}`);
293
793
  }
@@ -304,6 +804,11 @@ function expectedReceiptReference(serialized, explicitHash) {
304
804
  function findMandates(parsedCredentials, vct) {
305
805
  return parsedCredentials.flatMap((parsed) => parsed.mandateValues.filter((mandate) => mandate['vct'] === vct));
306
806
  }
807
+ function findMandateDigests(parsedCredentials, vct) {
808
+ return parsedCredentials.flatMap((parsed) => parsed.mandateDisclosures
809
+ .filter((mandate) => mandate.value['vct'] === vct)
810
+ .map((mandate) => mandate.digest));
811
+ }
307
812
  function checkCheckoutHash(mandates, errors) {
308
813
  for (const mandate of mandates) {
309
814
  const checkoutJwt = mandate['checkout_jwt'];
@@ -426,12 +931,176 @@ function checkTimeWindow(parsedCredentials, nowSeconds, clockSkewSeconds, errors
426
931
  const iat = parsed.payload['iat'];
427
932
  if (isNumber(iat) && nowSeconds + clockSkewSeconds < iat)
428
933
  errors.push('vi_credential_iat_in_future');
934
+ const nbf = parsed.payload['nbf'];
935
+ if (isNumber(nbf) && nowSeconds + clockSkewSeconds < nbf)
936
+ errors.push('vi_credential_nbf_in_future');
429
937
  }
430
938
  }
939
+ function sdJwtHasher(data, alg) {
940
+ if (alg !== 'sha-256')
941
+ throw new Error('hash_alg_unsupported');
942
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : new Uint8Array(data);
943
+ return sha256(bytes);
944
+ }
945
+ function sdJwtVerifier(publicJwk) {
946
+ return (data, signature) => verifyEs256Signature(data, base64urlDecode(signature), publicJwk);
947
+ }
948
+ function credentialVerificationKey(parsed, trustedIssuerKeys, byLayer) {
949
+ const kid = isString(parsed.header['kid']) ? parsed.header['kid'] : null;
950
+ if (parsed.input.layer === 'L1')
951
+ return findIssuerKey(kid, trustedIssuerKeys);
952
+ const l1UserKey = getCnfJwk(byLayer.get('L1')?.payload);
953
+ if (parsed.input.layer === 'L2')
954
+ return l1UserKey;
955
+ const l2AgentKeys = byLayer
956
+ .get('L2')
957
+ ?.mandateValues.map(getCnfJwk)
958
+ .filter((jwk) => jwk !== undefined) ?? [];
959
+ return l2AgentKeys.find((candidate) => getKid(candidate) === kid);
960
+ }
961
+ function sdJwtErrorReason(error) {
962
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
963
+ if (message.includes('status_fetcher_unconfigured'))
964
+ return 'status_fetcher';
965
+ if (message.includes('vct_fetcher_unconfigured'))
966
+ return 'vct_fetcher';
967
+ if (message.includes('hash') || message.includes('digest'))
968
+ return 'disclosure_digest';
969
+ if (message.includes('signature'))
970
+ return 'signature';
971
+ if (message.includes('expired') || message.includes('exp'))
972
+ return 'exp';
973
+ if (message.includes('nbf'))
974
+ return 'nbf';
975
+ if (message.includes('iat'))
976
+ return 'iat';
977
+ if (message.includes('key binding') || message.includes('kb'))
978
+ return 'key_binding';
979
+ if (message.includes('vct'))
980
+ return 'vct';
981
+ if (message.includes('status'))
982
+ return 'status';
983
+ return 'invalid';
984
+ }
985
+ function addSdJwtConformanceFinding(policy, code, layer, errors, warnings) {
986
+ if (policy === 'require')
987
+ errors.push(code);
988
+ else
989
+ warnings.push(`${code}:${layer}`);
990
+ }
991
+ function addConstraintFindings(policy, constraints, errors, warnings) {
992
+ for (const check of constraints.checks) {
993
+ let code;
994
+ if (check.status === 'failed')
995
+ code = `vi_constraint_failed:${check.type}`;
996
+ if (check.status === 'unresolved')
997
+ code = `vi_constraint_unresolved:${check.type}`;
998
+ if (check.status === 'unsupported')
999
+ code = `vi_constraint_unsupported:${check.type}`;
1000
+ if (code === undefined)
1001
+ continue;
1002
+ if (policy === 'require')
1003
+ errors.push(code);
1004
+ else
1005
+ warnings.push(code);
1006
+ }
1007
+ }
1008
+ async function verifyCredentialSdJwtConformance(credentials, trustedIssuerKeys, policy, profile, options, nowSeconds, clockSkewSeconds) {
1009
+ const errors = [];
1010
+ const warnings = [];
1011
+ const parsedCredentials = [];
1012
+ const checks = credentials.map(() => sdJwtConformanceCheck('not_checked', profile));
1013
+ for (let index = 0; index < credentials.length; index += 1) {
1014
+ const credential = credentials[index];
1015
+ try {
1016
+ parsedCredentials.push({ index, parsed: parseCredential(credential) });
1017
+ }
1018
+ catch {
1019
+ checks[index] = sdJwtConformanceCheck('invalid', profile, 'parse');
1020
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', credential.layer, errors, warnings);
1021
+ }
1022
+ }
1023
+ const byLayer = new Map();
1024
+ for (const { parsed } of parsedCredentials)
1025
+ byLayer.set(parsed.input.layer, parsed);
1026
+ for (const { index: checkIndex, parsed } of parsedCredentials) {
1027
+ const key = credentialVerificationKey(parsed, trustedIssuerKeys, byLayer);
1028
+ const structuralErrors = credentialStructuralErrors(parsed);
1029
+ if (parsed.header['alg'] !== 'ES256') {
1030
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, 'alg');
1031
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1032
+ continue;
1033
+ }
1034
+ if (structuralErrors.length > 0) {
1035
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, structuralErrors[0]);
1036
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1037
+ continue;
1038
+ }
1039
+ if (!key) {
1040
+ checks[checkIndex] = sdJwtConformanceCheck('not_checked', profile, 'missing_key');
1041
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_key_missing', parsed.input.layer, errors, warnings);
1042
+ continue;
1043
+ }
1044
+ if (disclosureDigestsOk(parsed) === false) {
1045
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, 'disclosure_digest');
1046
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1047
+ continue;
1048
+ }
1049
+ try {
1050
+ const verifier = sdJwtVerifier(key);
1051
+ const verifyOptions = { currentDate: nowSeconds, skewSeconds: clockSkewSeconds };
1052
+ if (profile === 'sd-jwt-vc') {
1053
+ const vcOptions = options.sdJwtVc ?? {};
1054
+ const statusListFetcher = vcOptions.statusListFetcher ??
1055
+ (async () => {
1056
+ throw new Error('status_fetcher_unconfigured');
1057
+ });
1058
+ const vctFetcher = vcOptions.vctFetcher ??
1059
+ (vcOptions.loadTypeMetadata
1060
+ ? async () => {
1061
+ throw new Error('vct_fetcher_unconfigured');
1062
+ }
1063
+ : undefined);
1064
+ const vcConfig = {
1065
+ hasher: sdJwtHasher,
1066
+ hashAlg: 'sha-256',
1067
+ verifier,
1068
+ loadTypeMetadataFormat: vcOptions.loadTypeMetadata === true,
1069
+ statusListFetcher,
1070
+ };
1071
+ if (vcOptions.maxVctExtendsDepth !== undefined)
1072
+ Object.assign(vcConfig, { maxVctExtendsDepth: vcOptions.maxVctExtendsDepth });
1073
+ if (vcOptions.statusValidator !== undefined)
1074
+ Object.assign(vcConfig, { statusValidator: vcOptions.statusValidator });
1075
+ if (vctFetcher !== undefined)
1076
+ Object.assign(vcConfig, { vctFetcher });
1077
+ const verifierInstance = new SDJwtVcInstance(vcConfig);
1078
+ await verifierInstance.verify(parsed.input.sdJwt, verifyOptions);
1079
+ await verifierInstance.getClaims(parsed.input.sdJwt);
1080
+ }
1081
+ else {
1082
+ const verifierInstance = new SDJwtInstance({
1083
+ hasher: sdJwtHasher,
1084
+ hashAlg: 'sha-256',
1085
+ verifier,
1086
+ });
1087
+ await verifierInstance.verify(parsed.input.sdJwt, verifyOptions);
1088
+ await verifierInstance.getClaims(parsed.input.sdJwt);
1089
+ }
1090
+ checks[checkIndex] = sdJwtConformanceCheck('verified', profile);
1091
+ }
1092
+ catch (error) {
1093
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, sdJwtErrorReason(error));
1094
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1095
+ }
1096
+ }
1097
+ return { checks, errors, warnings };
1098
+ }
431
1099
  export function verifyAp2ViEvidence(bundle, options = {}) {
432
1100
  const errors = [];
433
1101
  const warnings = [];
434
1102
  const signaturePolicy = options.signaturePolicy ?? 'require';
1103
+ const constraintPolicy = options.constraintPolicy ?? 'require';
435
1104
  const trustedIssuerKeys = options.trustedIssuerKeys ?? bundle.trustedIssuerKeys ?? [];
436
1105
  const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
437
1106
  const clockSkewSeconds = options.clockSkewSeconds ?? 300;
@@ -447,6 +1116,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
447
1116
  errors.push('vi_typ_mismatch');
448
1117
  if (check.disclosuresOk === false)
449
1118
  errors.push('vi_disclosure_digest_mismatch');
1119
+ errors.push(...credentialStructuralErrors(parsed));
450
1120
  }
451
1121
  catch (error) {
452
1122
  const code = error instanceof Error && error.message ? error.message : 'vi_jwt_malformed';
@@ -457,6 +1127,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
457
1127
  typ: null,
458
1128
  kid: null,
459
1129
  signature: signatureCheck('invalid', 'parse'),
1130
+ sdJwtConformance: sdJwtConformanceCheck('invalid', null, 'parse'),
460
1131
  sdHashOk: null,
461
1132
  disclosuresOk: null,
462
1133
  mandateVcts: [],
@@ -470,6 +1141,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
470
1141
  const finalCheckoutMandates = findMandates(parsedCredentials, 'mandate.checkout.1');
471
1142
  const openPaymentMandates = findMandates(parsedCredentials, 'mandate.payment.open.1');
472
1143
  const openCheckoutMandates = findMandates(parsedCredentials, 'mandate.checkout.open.1');
1144
+ const openCheckoutMandateDigests = findMandateDigests(parsedCredentials, 'mandate.checkout.open.1');
473
1145
  checkCheckoutHash(finalCheckoutMandates, errors);
474
1146
  const checkoutPaymentBindingOk = checkPaymentCheckoutBinding(finalPaymentMandates, finalCheckoutMandates, errors);
475
1147
  const delegationOk = checkDelegation(openCheckoutMandates, openPaymentMandates, errors);
@@ -478,6 +1150,21 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
478
1150
  : finalPaymentMandates.length > 0 || finalCheckoutMandates.length > 0
479
1151
  ? 'immediate'
480
1152
  : 'unknown';
1153
+ const constraints = constraintPolicy === 'off'
1154
+ ? { status: 'not_checked', checks: [] }
1155
+ : evaluateAp2ViConstraints({
1156
+ openCheckoutMandates,
1157
+ openCheckoutMandateDigests,
1158
+ closedCheckoutMandates: finalCheckoutMandates,
1159
+ openPaymentMandates,
1160
+ closedPaymentMandates: finalPaymentMandates,
1161
+ checkoutPayload: bundle.ap2?.checkoutPayload,
1162
+ checkoutPaymentBindingOk,
1163
+ nowSeconds,
1164
+ }, disclosureMap(parsedCredentials));
1165
+ if (constraintPolicy !== 'off') {
1166
+ addConstraintFindings(constraintPolicy, constraints, errors, warnings);
1167
+ }
481
1168
  const ap2 = {};
482
1169
  const expectedPaymentReference = expectedReceiptReference(bundle.ap2?.closedPaymentMandate, bundle.ap2?.closedPaymentMandateHash);
483
1170
  const expectedCheckoutReference = expectedReceiptReference(bundle.ap2?.closedCheckoutMandate, bundle.ap2?.closedCheckoutMandateHash);
@@ -512,6 +1199,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
512
1199
  credentials: credentialChecks,
513
1200
  delegationOk,
514
1201
  checkoutPaymentBindingOk,
1202
+ constraints,
515
1203
  },
516
1204
  errors: [...new Set(errors)],
517
1205
  warnings: [...new Set(warnings)],
@@ -521,10 +1209,14 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
521
1209
  const ap2 = { ...bundle.ap2 };
522
1210
  const receiptJwtIssuers = options.receiptJwtIssuers ?? bundle.receiptJwtIssuers ?? [];
523
1211
  const receiptJwtPolicy = options.receiptJwtPolicy ?? 'require';
1212
+ const sdJwtConformancePolicy = options.sdJwtConformancePolicy ?? 'require';
1213
+ const sdJwtConformanceProfile = options.sdJwtConformanceProfile ?? 'sd-jwt-vc';
524
1214
  const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
525
1215
  const clockSkewSeconds = options.clockSkewSeconds ?? 300;
526
1216
  const jwtErrors = [];
527
1217
  const jwtWarnings = [];
1218
+ const sdJwtErrors = [];
1219
+ const sdJwtWarnings = [];
528
1220
  const paymentJwt = ap2.paymentReceiptJwt
529
1221
  ? await verifyReceiptJwt('payment', ap2.paymentReceiptJwt, receiptJwtIssuers, receiptJwtPolicy, nowSeconds, clockSkewSeconds, options.fetch)
530
1222
  : undefined;
@@ -561,6 +1253,18 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
561
1253
  }
562
1254
  }
563
1255
  const result = verifyAp2ViEvidence({ ...bundle, ap2 }, options);
1256
+ if (sdJwtConformancePolicy !== 'off') {
1257
+ const credentials = bundle.vi?.credentials ?? [];
1258
+ const trustedIssuerKeys = options.trustedIssuerKeys ?? bundle.trustedIssuerKeys ?? [];
1259
+ const sdJwt = await verifyCredentialSdJwtConformance(credentials, trustedIssuerKeys, sdJwtConformancePolicy, sdJwtConformanceProfile, options, nowSeconds, clockSkewSeconds);
1260
+ for (let index = 0; index < sdJwt.checks.length; index += 1) {
1261
+ const check = result.vi.credentials[index];
1262
+ if (check)
1263
+ check.sdJwtConformance = sdJwt.checks[index];
1264
+ }
1265
+ sdJwtErrors.push(...sdJwt.errors);
1266
+ sdJwtWarnings.push(...sdJwt.warnings);
1267
+ }
564
1268
  if (paymentJwt) {
565
1269
  if (result.ap2.paymentReceipt)
566
1270
  result.ap2.paymentReceipt.jwt = paymentJwt.check;
@@ -573,8 +1277,8 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
573
1277
  else
574
1278
  result.ap2.checkoutReceipt = emptyJwtReceiptCheck(checkoutJwt.check);
575
1279
  }
576
- const errors = [...new Set([...result.errors, ...jwtErrors])];
577
- const warnings = [...new Set([...result.warnings, ...jwtWarnings])];
1280
+ const errors = [...new Set([...result.errors, ...jwtErrors, ...sdJwtErrors])];
1281
+ const warnings = [...new Set([...result.warnings, ...jwtWarnings, ...sdJwtWarnings])];
578
1282
  const hasAp2Receipt = result.ap2.paymentReceipt?.present === true || result.ap2.checkoutReceipt?.present === true;
579
1283
  return {
580
1284
  ...result,