@atrib/verify 0.3.7 → 0.4.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;
@@ -97,22 +111,439 @@ function sameJson(left, right) {
97
111
  const rightCanonical = canonicalJson(right);
98
112
  return leftCanonical !== null && leftCanonical === rightCanonical;
99
113
  }
114
+ function getConstraintObjects(mandate) {
115
+ const constraints = mandate['constraints'];
116
+ if (!Array.isArray(constraints))
117
+ return [];
118
+ return constraints.filter(isRecord);
119
+ }
120
+ function normalizeConstraintType(value) {
121
+ return value.startsWith('mandate.') ? value.slice('mandate.'.length) : value;
122
+ }
123
+ function makeConstraintCheck(constraint, domain, status, reason) {
124
+ const type = isString(constraint['type']) ? constraint['type'] : `${domain}.unknown`;
125
+ return reason === undefined ? { type, domain, status } : { type, domain, status, reason };
126
+ }
127
+ function resolveDisclosureRefs(value, disclosures) {
128
+ if (Array.isArray(value))
129
+ return value.map((item) => resolveDisclosureRefs(item, disclosures));
130
+ if (!isRecord(value))
131
+ return value;
132
+ const digest = value['...'];
133
+ if (isString(digest) && Object.keys(value).length === 1) {
134
+ return disclosures.has(digest)
135
+ ? resolveDisclosureRefs(disclosures.get(digest), disclosures)
136
+ : value;
137
+ }
138
+ const resolved = {};
139
+ for (const [key, nested] of Object.entries(value)) {
140
+ resolved[key] = resolveDisclosureRefs(nested, disclosures);
141
+ }
142
+ return resolved;
143
+ }
144
+ function disclosureMap(parsedCredentials) {
145
+ const disclosures = new Map();
146
+ for (const parsed of parsedCredentials) {
147
+ for (const disclosure of parsed.disclosures) {
148
+ disclosures.set(disclosure.digest, disclosure.value);
149
+ }
150
+ }
151
+ return disclosures;
152
+ }
153
+ function comparableFields(left, right, fields) {
154
+ return fields.filter((field) => isString(left[field]) && isString(right[field]));
155
+ }
156
+ function entityMatches(left, right, fields) {
157
+ if (!isRecord(left) || !isRecord(right))
158
+ return false;
159
+ const fieldsToCompare = comparableFields(left, right, fields);
160
+ return (fieldsToCompare.length > 0 && fieldsToCompare.every((field) => left[field] === right[field]));
161
+ }
162
+ function anyEntityMatches(allowed, actual, fields) {
163
+ if (!Array.isArray(allowed))
164
+ return null;
165
+ if (!isRecord(actual))
166
+ return null;
167
+ const allowedRecords = allowed.filter(isRecord);
168
+ if (allowedRecords.length === 0)
169
+ return null;
170
+ return allowedRecords.some((candidate) => entityMatches(candidate, actual, fields));
171
+ }
172
+ function paymentAmount(target) {
173
+ const amount = target['payment_amount'];
174
+ if (!isRecord(amount) || !isSafeInteger(amount['amount']) || !isString(amount['currency'])) {
175
+ return null;
176
+ }
177
+ return { amount: amount['amount'], currency: amount['currency'] };
178
+ }
179
+ function optionalIntegerBound(value) {
180
+ if (value === undefined)
181
+ return undefined;
182
+ return isSafeInteger(value) ? value : null;
183
+ }
184
+ function compareIsoDate(value, lower, upper) {
185
+ const time = Date.parse(value);
186
+ if (!Number.isFinite(time))
187
+ return null;
188
+ if (isString(lower)) {
189
+ const lowerTime = Date.parse(lower);
190
+ if (!Number.isFinite(lowerTime) || time < lowerTime)
191
+ return false;
192
+ }
193
+ if (isString(upper)) {
194
+ const upperTime = Date.parse(upper);
195
+ if (!Number.isFinite(upperTime) || time > upperTime)
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+ function decodeCompactJwtPayload(value) {
201
+ try {
202
+ const payload = decodeJwt(value);
203
+ return isRecord(payload) ? payload : undefined;
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
209
+ function resolveCheckoutPayload(explicitPayload, closedCheckoutMandates) {
210
+ if (isRecord(explicitPayload))
211
+ return explicitPayload;
212
+ for (const mandate of closedCheckoutMandates) {
213
+ const payload = mandate['checkout_payload'];
214
+ if (isRecord(payload))
215
+ return payload;
216
+ const checkoutJwt = mandate['checkout_jwt'];
217
+ if (isString(checkoutJwt)) {
218
+ const decoded = decodeCompactJwtPayload(checkoutJwt);
219
+ if (decoded)
220
+ return decoded;
221
+ }
222
+ }
223
+ return undefined;
224
+ }
225
+ function checkoutLineItemId(value) {
226
+ const product = value['product'];
227
+ if (isRecord(product) && isString(product['id']))
228
+ return product['id'];
229
+ return isString(value['id']) ? value['id'] : null;
230
+ }
231
+ function checkoutLineItemQuantities(checkoutPayload) {
232
+ const items = checkoutPayload['line_items'];
233
+ if (!Array.isArray(items))
234
+ return null;
235
+ const quantities = new Map();
236
+ for (const item of items) {
237
+ if (!isRecord(item))
238
+ return null;
239
+ const id = checkoutLineItemId(item);
240
+ const quantity = item['quantity'];
241
+ if (id === null || !isSafeInteger(quantity) || quantity < 0)
242
+ return null;
243
+ quantities.set(id, (quantities.get(id) ?? 0) + quantity);
244
+ }
245
+ return quantities;
246
+ }
247
+ function maxLineItemFlow(requirements, checkoutQuantities) {
248
+ const source = 'source';
249
+ const sink = 'sink';
250
+ const graph = new Map();
251
+ const addEdge = (from, to, capacity) => {
252
+ if (!graph.has(from))
253
+ graph.set(from, new Map());
254
+ if (!graph.has(to))
255
+ graph.set(to, new Map());
256
+ graph.get(from).set(to, (graph.get(from).get(to) ?? 0) + capacity);
257
+ graph.get(to).set(from, graph.get(to).get(from) ?? 0);
258
+ };
259
+ requirements.forEach((requirement, index) => {
260
+ const reqNode = `req:${index}`;
261
+ addEdge(source, reqNode, requirement.quantity);
262
+ for (const itemId of requirement.acceptableIds) {
263
+ if (checkoutQuantities.has(itemId))
264
+ addEdge(reqNode, `item:${itemId}`, Number.MAX_SAFE_INTEGER);
265
+ }
266
+ });
267
+ for (const [itemId, quantity] of checkoutQuantities) {
268
+ addEdge(`item:${itemId}`, sink, quantity);
269
+ }
270
+ let flow = 0;
271
+ for (;;) {
272
+ const parent = new Map();
273
+ const queue = [source];
274
+ parent.set(source, '');
275
+ for (let i = 0; i < queue.length && !parent.has(sink); i += 1) {
276
+ const node = queue[i];
277
+ for (const [next, capacity] of graph.get(node) ?? []) {
278
+ if (capacity > 0 && !parent.has(next)) {
279
+ parent.set(next, node);
280
+ queue.push(next);
281
+ }
282
+ }
283
+ }
284
+ if (!parent.has(sink))
285
+ return flow;
286
+ let increment = Number.MAX_SAFE_INTEGER;
287
+ for (let node = sink; node !== source; node = parent.get(node)) {
288
+ const prev = parent.get(node);
289
+ increment = Math.min(increment, graph.get(prev).get(node));
290
+ }
291
+ for (let node = sink; node !== source; node = parent.get(node)) {
292
+ const prev = parent.get(node);
293
+ graph.get(prev).set(node, graph.get(prev).get(node) - increment);
294
+ graph.get(node).set(prev, graph.get(node).get(prev) + increment);
295
+ }
296
+ flow += increment;
297
+ }
298
+ }
299
+ function evaluatePaymentConstraint(constraint, closedPaymentMandates, nowSeconds) {
300
+ const type = isString(constraint['type']) ? normalizeConstraintType(constraint['type']) : '';
301
+ if (closedPaymentMandates.length === 0) {
302
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'closed_payment_missing');
303
+ }
304
+ if (type === 'payment.amount_range') {
305
+ const min = optionalIntegerBound(constraint['min']);
306
+ const max = optionalIntegerBound(constraint['max']);
307
+ if (min === null || max === null) {
308
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'amount_range');
309
+ }
310
+ for (const mandate of closedPaymentMandates) {
311
+ const amount = paymentAmount(mandate);
312
+ if (amount === null) {
313
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'payment_amount');
314
+ }
315
+ if (isString(constraint['currency']) && constraint['currency'] !== amount.currency) {
316
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'currency');
317
+ }
318
+ if (min !== undefined && amount.amount < min) {
319
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'min');
320
+ }
321
+ if (max !== undefined && amount.amount > max) {
322
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'max');
323
+ }
324
+ }
325
+ return makeConstraintCheck(constraint, 'payment', 'passed');
326
+ }
327
+ if (type === 'payment.allowed_payees') {
328
+ for (const mandate of closedPaymentMandates) {
329
+ const ok = anyEntityMatches(constraint['allowed'], mandate['payee'], [
330
+ 'id',
331
+ 'website',
332
+ 'name',
333
+ ]);
334
+ if (ok === null)
335
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
336
+ if (!ok)
337
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'payee');
338
+ }
339
+ return makeConstraintCheck(constraint, 'payment', 'passed');
340
+ }
341
+ if (type === 'payment.allowed_payment_instruments') {
342
+ for (const mandate of closedPaymentMandates) {
343
+ const ok = anyEntityMatches(constraint['allowed'], mandate['payment_instrument'], [
344
+ 'id',
345
+ 'type',
346
+ 'description',
347
+ ]);
348
+ if (ok === null)
349
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
350
+ if (!ok)
351
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'payment_instrument');
352
+ }
353
+ return makeConstraintCheck(constraint, 'payment', 'passed');
354
+ }
355
+ if (type === 'payment.allowed_pisps') {
356
+ for (const mandate of closedPaymentMandates) {
357
+ const ok = anyEntityMatches(constraint['allowed'], mandate['pisp'], [
358
+ 'domain_name',
359
+ 'brand_name',
360
+ 'legal_name',
361
+ ]);
362
+ if (ok === null)
363
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'allowed');
364
+ if (!ok)
365
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'pisp');
366
+ }
367
+ return makeConstraintCheck(constraint, 'payment', 'passed');
368
+ }
369
+ if (type === 'payment.execution_date') {
370
+ const immediateExecutionDate = new Date(nowSeconds * 1000).toISOString();
371
+ for (const mandate of closedPaymentMandates) {
372
+ const executionDate = isString(mandate['execution_date'])
373
+ ? mandate['execution_date']
374
+ : immediateExecutionDate;
375
+ const ok = compareIsoDate(executionDate, constraint['not_before'], constraint['not_after']);
376
+ if (ok === null)
377
+ return makeConstraintCheck(constraint, 'payment', 'unresolved', 'date');
378
+ if (!ok)
379
+ return makeConstraintCheck(constraint, 'payment', 'failed', 'execution_date');
380
+ }
381
+ return makeConstraintCheck(constraint, 'payment', 'passed');
382
+ }
383
+ return makeConstraintCheck(constraint, 'payment', 'unsupported');
384
+ }
385
+ function evaluateCheckoutConstraint(constraint, checkoutPayload, closedPaymentMandates) {
386
+ const type = isString(constraint['type']) ? normalizeConstraintType(constraint['type']) : '';
387
+ if (type === 'checkout.allowed_merchants') {
388
+ const merchant = checkoutPayload?.['merchant'] ??
389
+ closedPaymentMandates.find((mandate) => mandate['payee'] !== undefined)?.['payee'];
390
+ const ok = anyEntityMatches(constraint['allowed'], merchant, ['id', 'website', 'name']);
391
+ if (ok === null)
392
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'merchant');
393
+ if (!ok)
394
+ return makeConstraintCheck(constraint, 'checkout', 'failed', 'merchant');
395
+ return makeConstraintCheck(constraint, 'checkout', 'passed');
396
+ }
397
+ if (type === 'checkout.line_items') {
398
+ if (!checkoutPayload) {
399
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'checkout_payload_missing');
400
+ }
401
+ const checkoutQuantities = checkoutLineItemQuantities(checkoutPayload);
402
+ const items = constraint['items'];
403
+ if (checkoutQuantities === null || !Array.isArray(items)) {
404
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'line_items');
405
+ }
406
+ const requirements = [];
407
+ for (const item of items) {
408
+ if (!isRecord(item)) {
409
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'requirement');
410
+ }
411
+ const quantity = item['quantity'];
412
+ if (!isSafeInteger(quantity) || quantity < 0) {
413
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'requirement');
414
+ }
415
+ const acceptableItems = item['acceptable_items'];
416
+ if (!Array.isArray(acceptableItems)) {
417
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'acceptable_items');
418
+ }
419
+ const acceptableIds = new Set();
420
+ for (const acceptable of acceptableItems) {
421
+ if (isRecord(acceptable) && isString(acceptable['id']))
422
+ acceptableIds.add(acceptable['id']);
423
+ }
424
+ if (acceptableIds.size === 0) {
425
+ return makeConstraintCheck(constraint, 'checkout', 'unresolved', 'acceptable_items');
426
+ }
427
+ requirements.push({ quantity, acceptableIds });
428
+ }
429
+ const requiredQuantity = requirements.reduce((sum, item) => sum + item.quantity, 0);
430
+ const checkoutQuantity = [...checkoutQuantities.values()].reduce((sum, quantity) => sum + quantity, 0);
431
+ const maxFlow = maxLineItemFlow(requirements, checkoutQuantities);
432
+ if (maxFlow !== requiredQuantity || requiredQuantity !== checkoutQuantity) {
433
+ return makeConstraintCheck(constraint, 'checkout', 'failed', 'line_items');
434
+ }
435
+ return makeConstraintCheck(constraint, 'checkout', 'passed');
436
+ }
437
+ return makeConstraintCheck(constraint, 'checkout', 'unsupported');
438
+ }
439
+ function summarizeConstraintChecks(checks) {
440
+ if (checks.length === 0)
441
+ return 'not_applicable';
442
+ if (checks.some((check) => check.status === 'failed'))
443
+ return 'failed';
444
+ if (checks.some((check) => check.status === 'unresolved' || check.status === 'unsupported')) {
445
+ return 'unresolved';
446
+ }
447
+ return 'passed';
448
+ }
449
+ export function evaluateAp2ViConstraints(input, disclosures = new Map()) {
450
+ const openCheckoutMandates = (input.openCheckoutMandates ?? [])
451
+ .filter(isRecord)
452
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
453
+ .filter(isRecord);
454
+ const closedCheckoutMandates = (input.closedCheckoutMandates ?? [])
455
+ .filter(isRecord)
456
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
457
+ .filter(isRecord);
458
+ const openPaymentMandates = (input.openPaymentMandates ?? [])
459
+ .filter(isRecord)
460
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
461
+ .filter(isRecord);
462
+ const closedPaymentMandates = (input.closedPaymentMandates ?? [])
463
+ .filter(isRecord)
464
+ .map((mandate) => resolveDisclosureRefs(mandate, disclosures))
465
+ .filter(isRecord);
466
+ const resolvedCheckoutPayload = resolveDisclosureRefs(input.checkoutPayload, disclosures);
467
+ const checkoutPayload = resolveCheckoutPayload(resolvedCheckoutPayload, closedCheckoutMandates);
468
+ const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
469
+ const checks = [];
470
+ for (const mandate of openCheckoutMandates) {
471
+ for (const constraint of getConstraintObjects(mandate)) {
472
+ const resolved = resolveDisclosureRefs(constraint, disclosures);
473
+ checks.push(evaluateCheckoutConstraint(isRecord(resolved) ? resolved : constraint, checkoutPayload, closedPaymentMandates));
474
+ }
475
+ }
476
+ for (const mandate of openPaymentMandates) {
477
+ for (const constraint of getConstraintObjects(mandate)) {
478
+ const resolved = resolveDisclosureRefs(constraint, disclosures);
479
+ checks.push(evaluatePaymentConstraint(isRecord(resolved) ? resolved : constraint, closedPaymentMandates, nowSeconds));
480
+ }
481
+ }
482
+ return { status: summarizeConstraintChecks(checks), checks };
483
+ }
100
484
  function normalizeJwks(value) {
101
485
  const keys = Array.isArray(value) ? value : value.keys;
486
+ if (keys.length === 0)
487
+ throw new Error('jwks_empty');
488
+ const seenKids = new Set();
489
+ for (const key of keys)
490
+ validateReceiptJwk(key, seenKids);
102
491
  return { keys: keys };
103
492
  }
104
- function verifyJwtSignature(parsed, publicJwk) {
493
+ function verifyEs256Signature(data, signature, publicJwk) {
105
494
  const key = createPublicKey({ key: publicJwk, format: 'jwk' });
106
- return verifyEcdsa('sha256', Buffer.from(parsed.signingInput, 'ascii'), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(parsed.signature));
495
+ return verifyEcdsa('sha256', Buffer.from(data, 'ascii'), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature));
496
+ }
497
+ function verifyJwtSignature(parsed, publicJwk) {
498
+ return verifyEs256Signature(parsed.signingInput, parsed.signature, publicJwk);
107
499
  }
108
500
  function findIssuerKey(kid, keys) {
109
501
  if (kid === null)
110
502
  return keys[0];
111
503
  return keys.find((key) => getKid(key) === kid);
112
504
  }
505
+ function validateReceiptJwk(key, seenKids) {
506
+ const record = key;
507
+ const kid = getKid(record);
508
+ if (kid !== null) {
509
+ if (seenKids.has(kid))
510
+ throw new Error('jwks_duplicate_kid');
511
+ seenKids.add(kid);
512
+ }
513
+ if (record['kty'] !== 'EC' || record['crv'] !== 'P-256') {
514
+ throw new Error('jwks_key_unsupported');
515
+ }
516
+ const alg = record['alg'];
517
+ if (alg !== undefined && alg !== 'ES256') {
518
+ throw new Error('jwks_key_unsupported');
519
+ }
520
+ const use = record['use'];
521
+ if (use !== undefined && use !== 'sig') {
522
+ throw new Error('jwks_key_unsupported');
523
+ }
524
+ const keyOps = record['key_ops'];
525
+ if (keyOps !== undefined &&
526
+ (!Array.isArray(keyOps) || !keyOps.every(isString) || !keyOps.includes('verify'))) {
527
+ throw new Error('jwks_key_unsupported');
528
+ }
529
+ }
113
530
  function receiptJwtError(kind, code) {
114
531
  return `ap2_${kind}_receipt_jwt_${code}`;
115
532
  }
533
+ function normalizeReceiptJwtErrorCode(code) {
534
+ if (code === 'fetch_unavailable' ||
535
+ code === 'metadata_fetch_failed' ||
536
+ code === 'metadata_invalid' ||
537
+ code === 'metadata_jwks_missing' ||
538
+ code === 'key_source_missing' ||
539
+ code === 'jwks_empty' ||
540
+ code === 'jwks_duplicate_kid' ||
541
+ code === 'jwks_key_unsupported' ||
542
+ code === 'iat_in_future') {
543
+ return code === 'fetch_unavailable' ? 'metadata_fetch_unavailable' : code;
544
+ }
545
+ return 'invalid';
546
+ }
116
547
  function addReceiptJwtFailure(verification, kind, code, policy) {
117
548
  const error = receiptJwtError(kind, code);
118
549
  verification.check.error = error;
@@ -189,6 +620,23 @@ function disclosureDigestsOk(parsed) {
189
620
  const delegateDigests = collectDelegatePayloadDigests(parsed.payload);
190
621
  return parsed.disclosures.every((disclosure) => sdDigests.has(disclosure.digest) || delegateDigests.has(disclosure.digest));
191
622
  }
623
+ function credentialStructuralErrors(parsed) {
624
+ const errors = [];
625
+ if (hasDuplicateStrings(parsed.disclosures.map((disclosure) => disclosure.digest))) {
626
+ errors.push('vi_disclosure_duplicate');
627
+ }
628
+ const sdAlg = parsed.payload['_sd_alg'];
629
+ if (sdAlg !== undefined && sdAlg !== 'sha-256') {
630
+ errors.push('vi_sd_alg_unsupported');
631
+ }
632
+ const sd = parsed.payload['_sd'];
633
+ const sdDigests = Array.isArray(sd) ? sd.filter(isString) : [];
634
+ const delegateDigests = collectDelegatePayloadDigestList(parsed.payload);
635
+ if (hasDuplicateStrings(sdDigests) || hasDuplicateStrings(delegateDigests)) {
636
+ errors.push('vi_disclosure_digest_duplicate');
637
+ }
638
+ return errors;
639
+ }
192
640
  function initialCredentialCheck(parsed) {
193
641
  const alg = isString(parsed.header['alg']) ? parsed.header['alg'] : null;
194
642
  const typ = isString(parsed.header['typ']) ? parsed.header['typ'] : null;
@@ -199,6 +647,7 @@ function initialCredentialCheck(parsed) {
199
647
  typ,
200
648
  kid,
201
649
  signature: signatureCheck('not_checked'),
650
+ sdJwtConformance: sdJwtConformanceCheck('not_checked', null, 'async_required'),
202
651
  sdHashOk: null,
203
652
  disclosuresOk: disclosureDigestsOk(parsed),
204
653
  mandateVcts: parsed.mandateValues.map((mandate) => String(mandate['vct'])),
@@ -264,6 +713,14 @@ async function verifyReceiptJwt(kind, receiptJwt, issuers, policy, nowSeconds, c
264
713
  addReceiptJwtFailure(verification, kind, 'alg_unsupported', policy);
265
714
  return verification;
266
715
  }
716
+ if (header.crit !== undefined) {
717
+ addReceiptJwtFailure(verification, kind, 'crit_unsupported', policy);
718
+ return verification;
719
+ }
720
+ if (!isString(header.kid) || header.kid.length === 0) {
721
+ addReceiptJwtFailure(verification, kind, 'kid_missing', policy);
722
+ return verification;
723
+ }
267
724
  const issuerConfig = findReceiptJwtIssuer(issuer, issuers);
268
725
  if (!issuerConfig) {
269
726
  addReceiptJwtFailure(verification, kind, 'issuer_untrusted', policy);
@@ -281,13 +738,18 @@ async function verifyReceiptJwt(kind, receiptJwt, issuers, policy, nowSeconds, c
281
738
  if (issuerConfig.issuer !== undefined)
282
739
  verifyOptions.issuer = issuerConfig.issuer;
283
740
  const verified = await jwtVerify(receiptJwt, key.getKey, verifyOptions);
741
+ const iat = verified.payload.iat;
742
+ if (isNumber(iat) && nowSeconds + clockSkewSeconds < iat) {
743
+ addReceiptJwtFailure(verification, kind, 'iat_in_future', policy);
744
+ return verification;
745
+ }
284
746
  verification.check.verified = true;
285
747
  verification.payload = verified.payload;
286
748
  return verification;
287
749
  }
288
750
  catch (error) {
289
751
  const code = error instanceof Error && error.message ? error.message : 'invalid';
290
- addReceiptJwtFailure(verification, kind, code === 'fetch_unavailable' ? 'metadata_fetch_unavailable' : 'invalid', policy);
752
+ addReceiptJwtFailure(verification, kind, normalizeReceiptJwtErrorCode(code), policy);
291
753
  if (code !== 'invalid' && code !== 'fetch_unavailable') {
292
754
  verification.warnings.push(`${receiptJwtError(kind, 'detail')}:${code}`);
293
755
  }
@@ -426,12 +888,176 @@ function checkTimeWindow(parsedCredentials, nowSeconds, clockSkewSeconds, errors
426
888
  const iat = parsed.payload['iat'];
427
889
  if (isNumber(iat) && nowSeconds + clockSkewSeconds < iat)
428
890
  errors.push('vi_credential_iat_in_future');
891
+ const nbf = parsed.payload['nbf'];
892
+ if (isNumber(nbf) && nowSeconds + clockSkewSeconds < nbf)
893
+ errors.push('vi_credential_nbf_in_future');
894
+ }
895
+ }
896
+ function sdJwtHasher(data, alg) {
897
+ if (alg !== 'sha-256')
898
+ throw new Error('hash_alg_unsupported');
899
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : new Uint8Array(data);
900
+ return sha256(bytes);
901
+ }
902
+ function sdJwtVerifier(publicJwk) {
903
+ return (data, signature) => verifyEs256Signature(data, base64urlDecode(signature), publicJwk);
904
+ }
905
+ function credentialVerificationKey(parsed, trustedIssuerKeys, byLayer) {
906
+ const kid = isString(parsed.header['kid']) ? parsed.header['kid'] : null;
907
+ if (parsed.input.layer === 'L1')
908
+ return findIssuerKey(kid, trustedIssuerKeys);
909
+ const l1UserKey = getCnfJwk(byLayer.get('L1')?.payload);
910
+ if (parsed.input.layer === 'L2')
911
+ return l1UserKey;
912
+ const l2AgentKeys = byLayer
913
+ .get('L2')
914
+ ?.mandateValues.map(getCnfJwk)
915
+ .filter((jwk) => jwk !== undefined) ?? [];
916
+ return l2AgentKeys.find((candidate) => getKid(candidate) === kid);
917
+ }
918
+ function sdJwtErrorReason(error) {
919
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
920
+ if (message.includes('status_fetcher_unconfigured'))
921
+ return 'status_fetcher';
922
+ if (message.includes('vct_fetcher_unconfigured'))
923
+ return 'vct_fetcher';
924
+ if (message.includes('hash') || message.includes('digest'))
925
+ return 'disclosure_digest';
926
+ if (message.includes('signature'))
927
+ return 'signature';
928
+ if (message.includes('expired') || message.includes('exp'))
929
+ return 'exp';
930
+ if (message.includes('nbf'))
931
+ return 'nbf';
932
+ if (message.includes('iat'))
933
+ return 'iat';
934
+ if (message.includes('key binding') || message.includes('kb'))
935
+ return 'key_binding';
936
+ if (message.includes('vct'))
937
+ return 'vct';
938
+ if (message.includes('status'))
939
+ return 'status';
940
+ return 'invalid';
941
+ }
942
+ function addSdJwtConformanceFinding(policy, code, layer, errors, warnings) {
943
+ if (policy === 'require')
944
+ errors.push(code);
945
+ else
946
+ warnings.push(`${code}:${layer}`);
947
+ }
948
+ function addConstraintFindings(policy, constraints, errors, warnings) {
949
+ for (const check of constraints.checks) {
950
+ let code;
951
+ if (check.status === 'failed')
952
+ code = `vi_constraint_failed:${check.type}`;
953
+ if (check.status === 'unresolved')
954
+ code = `vi_constraint_unresolved:${check.type}`;
955
+ if (check.status === 'unsupported')
956
+ code = `vi_constraint_unsupported:${check.type}`;
957
+ if (code === undefined)
958
+ continue;
959
+ if (policy === 'require')
960
+ errors.push(code);
961
+ else
962
+ warnings.push(code);
429
963
  }
430
964
  }
965
+ async function verifyCredentialSdJwtConformance(credentials, trustedIssuerKeys, policy, profile, options, nowSeconds, clockSkewSeconds) {
966
+ const errors = [];
967
+ const warnings = [];
968
+ const parsedCredentials = [];
969
+ const checks = credentials.map(() => sdJwtConformanceCheck('not_checked', profile));
970
+ for (let index = 0; index < credentials.length; index += 1) {
971
+ const credential = credentials[index];
972
+ try {
973
+ parsedCredentials.push({ index, parsed: parseCredential(credential) });
974
+ }
975
+ catch {
976
+ checks[index] = sdJwtConformanceCheck('invalid', profile, 'parse');
977
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', credential.layer, errors, warnings);
978
+ }
979
+ }
980
+ const byLayer = new Map();
981
+ for (const { parsed } of parsedCredentials)
982
+ byLayer.set(parsed.input.layer, parsed);
983
+ for (const { index: checkIndex, parsed } of parsedCredentials) {
984
+ const key = credentialVerificationKey(parsed, trustedIssuerKeys, byLayer);
985
+ const structuralErrors = credentialStructuralErrors(parsed);
986
+ if (parsed.header['alg'] !== 'ES256') {
987
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, 'alg');
988
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
989
+ continue;
990
+ }
991
+ if (structuralErrors.length > 0) {
992
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, structuralErrors[0]);
993
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
994
+ continue;
995
+ }
996
+ if (!key) {
997
+ checks[checkIndex] = sdJwtConformanceCheck('not_checked', profile, 'missing_key');
998
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_key_missing', parsed.input.layer, errors, warnings);
999
+ continue;
1000
+ }
1001
+ if (disclosureDigestsOk(parsed) === false) {
1002
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, 'disclosure_digest');
1003
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1004
+ continue;
1005
+ }
1006
+ try {
1007
+ const verifier = sdJwtVerifier(key);
1008
+ const verifyOptions = { currentDate: nowSeconds, skewSeconds: clockSkewSeconds };
1009
+ if (profile === 'sd-jwt-vc') {
1010
+ const vcOptions = options.sdJwtVc ?? {};
1011
+ const statusListFetcher = vcOptions.statusListFetcher ??
1012
+ (async () => {
1013
+ throw new Error('status_fetcher_unconfigured');
1014
+ });
1015
+ const vctFetcher = vcOptions.vctFetcher ??
1016
+ (vcOptions.loadTypeMetadata
1017
+ ? async () => {
1018
+ throw new Error('vct_fetcher_unconfigured');
1019
+ }
1020
+ : undefined);
1021
+ const vcConfig = {
1022
+ hasher: sdJwtHasher,
1023
+ hashAlg: 'sha-256',
1024
+ verifier,
1025
+ loadTypeMetadataFormat: vcOptions.loadTypeMetadata === true,
1026
+ statusListFetcher,
1027
+ };
1028
+ if (vcOptions.maxVctExtendsDepth !== undefined)
1029
+ Object.assign(vcConfig, { maxVctExtendsDepth: vcOptions.maxVctExtendsDepth });
1030
+ if (vcOptions.statusValidator !== undefined)
1031
+ Object.assign(vcConfig, { statusValidator: vcOptions.statusValidator });
1032
+ if (vctFetcher !== undefined)
1033
+ Object.assign(vcConfig, { vctFetcher });
1034
+ const verifierInstance = new SDJwtVcInstance(vcConfig);
1035
+ await verifierInstance.verify(parsed.input.sdJwt, verifyOptions);
1036
+ await verifierInstance.getClaims(parsed.input.sdJwt);
1037
+ }
1038
+ else {
1039
+ const verifierInstance = new SDJwtInstance({
1040
+ hasher: sdJwtHasher,
1041
+ hashAlg: 'sha-256',
1042
+ verifier,
1043
+ });
1044
+ await verifierInstance.verify(parsed.input.sdJwt, verifyOptions);
1045
+ await verifierInstance.getClaims(parsed.input.sdJwt);
1046
+ }
1047
+ checks[checkIndex] = sdJwtConformanceCheck('verified', profile);
1048
+ }
1049
+ catch (error) {
1050
+ checks[checkIndex] = sdJwtConformanceCheck('invalid', profile, sdJwtErrorReason(error));
1051
+ addSdJwtConformanceFinding(policy, 'vi_sd_jwt_conformance_invalid', parsed.input.layer, errors, warnings);
1052
+ }
1053
+ }
1054
+ return { checks, errors, warnings };
1055
+ }
431
1056
  export function verifyAp2ViEvidence(bundle, options = {}) {
432
1057
  const errors = [];
433
1058
  const warnings = [];
434
1059
  const signaturePolicy = options.signaturePolicy ?? 'require';
1060
+ const constraintPolicy = options.constraintPolicy ?? 'require';
435
1061
  const trustedIssuerKeys = options.trustedIssuerKeys ?? bundle.trustedIssuerKeys ?? [];
436
1062
  const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
437
1063
  const clockSkewSeconds = options.clockSkewSeconds ?? 300;
@@ -447,6 +1073,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
447
1073
  errors.push('vi_typ_mismatch');
448
1074
  if (check.disclosuresOk === false)
449
1075
  errors.push('vi_disclosure_digest_mismatch');
1076
+ errors.push(...credentialStructuralErrors(parsed));
450
1077
  }
451
1078
  catch (error) {
452
1079
  const code = error instanceof Error && error.message ? error.message : 'vi_jwt_malformed';
@@ -457,6 +1084,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
457
1084
  typ: null,
458
1085
  kid: null,
459
1086
  signature: signatureCheck('invalid', 'parse'),
1087
+ sdJwtConformance: sdJwtConformanceCheck('invalid', null, 'parse'),
460
1088
  sdHashOk: null,
461
1089
  disclosuresOk: null,
462
1090
  mandateVcts: [],
@@ -478,6 +1106,19 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
478
1106
  : finalPaymentMandates.length > 0 || finalCheckoutMandates.length > 0
479
1107
  ? 'immediate'
480
1108
  : 'unknown';
1109
+ const constraints = constraintPolicy === 'off'
1110
+ ? { status: 'not_checked', checks: [] }
1111
+ : evaluateAp2ViConstraints({
1112
+ openCheckoutMandates,
1113
+ closedCheckoutMandates: finalCheckoutMandates,
1114
+ openPaymentMandates,
1115
+ closedPaymentMandates: finalPaymentMandates,
1116
+ checkoutPayload: bundle.ap2?.checkoutPayload,
1117
+ nowSeconds,
1118
+ }, disclosureMap(parsedCredentials));
1119
+ if (constraintPolicy !== 'off') {
1120
+ addConstraintFindings(constraintPolicy, constraints, errors, warnings);
1121
+ }
481
1122
  const ap2 = {};
482
1123
  const expectedPaymentReference = expectedReceiptReference(bundle.ap2?.closedPaymentMandate, bundle.ap2?.closedPaymentMandateHash);
483
1124
  const expectedCheckoutReference = expectedReceiptReference(bundle.ap2?.closedCheckoutMandate, bundle.ap2?.closedCheckoutMandateHash);
@@ -512,6 +1153,7 @@ export function verifyAp2ViEvidence(bundle, options = {}) {
512
1153
  credentials: credentialChecks,
513
1154
  delegationOk,
514
1155
  checkoutPaymentBindingOk,
1156
+ constraints,
515
1157
  },
516
1158
  errors: [...new Set(errors)],
517
1159
  warnings: [...new Set(warnings)],
@@ -521,10 +1163,14 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
521
1163
  const ap2 = { ...bundle.ap2 };
522
1164
  const receiptJwtIssuers = options.receiptJwtIssuers ?? bundle.receiptJwtIssuers ?? [];
523
1165
  const receiptJwtPolicy = options.receiptJwtPolicy ?? 'require';
1166
+ const sdJwtConformancePolicy = options.sdJwtConformancePolicy ?? 'require';
1167
+ const sdJwtConformanceProfile = options.sdJwtConformanceProfile ?? 'sd-jwt-vc';
524
1168
  const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
525
1169
  const clockSkewSeconds = options.clockSkewSeconds ?? 300;
526
1170
  const jwtErrors = [];
527
1171
  const jwtWarnings = [];
1172
+ const sdJwtErrors = [];
1173
+ const sdJwtWarnings = [];
528
1174
  const paymentJwt = ap2.paymentReceiptJwt
529
1175
  ? await verifyReceiptJwt('payment', ap2.paymentReceiptJwt, receiptJwtIssuers, receiptJwtPolicy, nowSeconds, clockSkewSeconds, options.fetch)
530
1176
  : undefined;
@@ -561,6 +1207,18 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
561
1207
  }
562
1208
  }
563
1209
  const result = verifyAp2ViEvidence({ ...bundle, ap2 }, options);
1210
+ if (sdJwtConformancePolicy !== 'off') {
1211
+ const credentials = bundle.vi?.credentials ?? [];
1212
+ const trustedIssuerKeys = options.trustedIssuerKeys ?? bundle.trustedIssuerKeys ?? [];
1213
+ const sdJwt = await verifyCredentialSdJwtConformance(credentials, trustedIssuerKeys, sdJwtConformancePolicy, sdJwtConformanceProfile, options, nowSeconds, clockSkewSeconds);
1214
+ for (let index = 0; index < sdJwt.checks.length; index += 1) {
1215
+ const check = result.vi.credentials[index];
1216
+ if (check)
1217
+ check.sdJwtConformance = sdJwt.checks[index];
1218
+ }
1219
+ sdJwtErrors.push(...sdJwt.errors);
1220
+ sdJwtWarnings.push(...sdJwt.warnings);
1221
+ }
564
1222
  if (paymentJwt) {
565
1223
  if (result.ap2.paymentReceipt)
566
1224
  result.ap2.paymentReceipt.jwt = paymentJwt.check;
@@ -573,8 +1231,8 @@ export async function verifyAp2ViEvidenceAsync(bundle, options = {}) {
573
1231
  else
574
1232
  result.ap2.checkoutReceipt = emptyJwtReceiptCheck(checkoutJwt.check);
575
1233
  }
576
- const errors = [...new Set([...result.errors, ...jwtErrors])];
577
- const warnings = [...new Set([...result.warnings, ...jwtWarnings])];
1234
+ const errors = [...new Set([...result.errors, ...jwtErrors, ...sdJwtErrors])];
1235
+ const warnings = [...new Set([...result.warnings, ...jwtWarnings, ...sdJwtWarnings])];
578
1236
  const hasAp2Receipt = result.ap2.paymentReceipt?.present === true || result.ap2.checkoutReceipt?.present === true;
579
1237
  return {
580
1238
  ...result,