@blamejs/core 0.8.59 → 0.8.64

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.
@@ -0,0 +1,514 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.auth.oid4vp
4
+ * @nav Identity
5
+ * @title OpenID4VP (verifier)
6
+ * @order 350
7
+ * @card OpenID for Verifiable Presentations 1.0 — verifier side
8
+ * with DCQL (Digital Credentials Query Language) support.
9
+ * Builds presentation requests, parses vp_token responses,
10
+ * and routes each presentation through the SD-JWT VC
11
+ * verifier with the right audience + nonce binding.
12
+ *
13
+ * @intro
14
+ * This module is the verifier counterpart to `b.auth.oid4vci`. The
15
+ * relying party (verifier) builds an authorization request asking
16
+ * the wallet for one or more verifiable presentations; the wallet
17
+ * replies with a `vp_token` carrying the SD-JWT VC presentations
18
+ * plus a `presentation_submission` (legacy Presentation Exchange
19
+ * 2.0) OR no submission when DCQL is used.
20
+ *
21
+ * DCQL (OpenID4VP 1.0 §6) is the JSON-shaped query language that
22
+ * replaces Presentation Exchange's JSONPath-soup. Two top-level
23
+ * keys:
24
+ *
25
+ * credentials: [
26
+ * {
27
+ * id: "id-card",
28
+ * format: "vc+sd-jwt",
29
+ * meta: { vct_values: ["https://example.com/vct/identity"] },
30
+ * claims: [
31
+ * { path: ["given_name"] },
32
+ * { path: ["birthdate"], values: ["1990-01-15"] },
33
+ * ],
34
+ * },
35
+ * ],
36
+ * credential_sets: [
37
+ * { options: [["id-card"], ["passport"]], required: true },
38
+ * ]
39
+ *
40
+ * The verifier-side primitives:
41
+ *
42
+ * b.auth.oid4vp.verifier.create({ ... })
43
+ * .createRequest({ dcql, audience, nonce, responseUri })
44
+ * .verifyResponse({ vpToken, dcql, audience, nonce })
45
+ * .matchDcql(presentations, dcql) // structural-only check
46
+ *
47
+ * `verifyResponse` composes `b.auth.sdJwtVc.verify` (with
48
+ * `requireKeyBinding: true` and the DCQL-claim filter applied to the
49
+ * disclosed-claim set), then runs `matchDcql` to confirm the
50
+ * wallet's selection satisfies the query.
51
+ */
52
+
53
+ var lazyRequire = require("../lazy-require");
54
+ var validateOpts = require("../validate-opts");
55
+ var { generateToken } = require("../crypto");
56
+ var { AuthError } = require("../framework-error");
57
+
58
+ var sdJwtVcCore = lazyRequire(function () { return require("./sd-jwt-vc"); });
59
+ var audit = lazyRequire(function () { return require("../audit"); });
60
+ var observability = lazyRequire(function () { return require("../observability"); });
61
+ var emit = validateOpts.makeNamespacedEmitters("auth.oid4vp", { audit: audit, observability: observability });
62
+
63
+ var _emitAudit = emit.audit;
64
+ var _emitMetric = emit.metric;
65
+
66
+ /**
67
+ * Validate a DCQL query against the spec shape. Refuses unknown
68
+ * top-level keys, missing credential id, missing claim paths, or
69
+ * malformed credential_sets options. Throws AuthError on first
70
+ * failure (config-time validation — the verifier author is the one
71
+ * who needs to see the error).
72
+ */
73
+ function _validateDcql(dcql) {
74
+ if (!dcql || typeof dcql !== "object" || Array.isArray(dcql)) {
75
+ throw new AuthError("auth-oid4vp/bad-dcql",
76
+ "DCQL: query must be a plain object");
77
+ }
78
+ if (!Array.isArray(dcql.credentials) || dcql.credentials.length === 0) {
79
+ throw new AuthError("auth-oid4vp/no-credentials",
80
+ "DCQL: query.credentials must be a non-empty array");
81
+ }
82
+ var seenIds = new Set();
83
+ dcql.credentials.forEach(function (cred, i) {
84
+ if (!cred || typeof cred !== "object") {
85
+ throw new AuthError("auth-oid4vp/bad-credential-query",
86
+ "DCQL: credentials[" + i + "] must be an object");
87
+ }
88
+ if (typeof cred.id !== "string" || cred.id.length === 0) {
89
+ throw new AuthError("auth-oid4vp/no-credential-id",
90
+ "DCQL: credentials[" + i + "].id is required");
91
+ }
92
+ if (seenIds.has(cred.id)) {
93
+ throw new AuthError("auth-oid4vp/duplicate-id",
94
+ "DCQL: credentials[" + i + "].id \"" + cred.id + "\" duplicated");
95
+ }
96
+ seenIds.add(cred.id);
97
+ if (typeof cred.format !== "string" || cred.format.length === 0) {
98
+ throw new AuthError("auth-oid4vp/no-format",
99
+ "DCQL: credentials['" + cred.id + "'].format is required");
100
+ }
101
+ if (cred.claims !== undefined) {
102
+ if (!Array.isArray(cred.claims)) {
103
+ throw new AuthError("auth-oid4vp/bad-claims",
104
+ "DCQL: credentials['" + cred.id + "'].claims must be an array");
105
+ }
106
+ cred.claims.forEach(function (claim, ci) {
107
+ if (!claim || typeof claim !== "object" || !Array.isArray(claim.path) || claim.path.length === 0) {
108
+ throw new AuthError("auth-oid4vp/bad-claim-path",
109
+ "DCQL: credentials['" + cred.id + "'].claims[" + ci + "].path must be a non-empty array");
110
+ }
111
+ claim.path.forEach(function (segment) {
112
+ if (typeof segment !== "string" && typeof segment !== "number" && segment !== null) {
113
+ throw new AuthError("auth-oid4vp/bad-claim-segment",
114
+ "DCQL: claim path segments must be string|number|null");
115
+ }
116
+ });
117
+ if (claim.values !== undefined && !Array.isArray(claim.values)) {
118
+ throw new AuthError("auth-oid4vp/bad-claim-values",
119
+ "DCQL: claim.values must be an array if present");
120
+ }
121
+ });
122
+ }
123
+ });
124
+ if (dcql.credential_sets !== undefined) {
125
+ if (!Array.isArray(dcql.credential_sets)) {
126
+ throw new AuthError("auth-oid4vp/bad-credential-sets",
127
+ "DCQL: credential_sets must be an array if present");
128
+ }
129
+ dcql.credential_sets.forEach(function (set, si) {
130
+ if (!set || typeof set !== "object" || !Array.isArray(set.options) || set.options.length === 0) {
131
+ throw new AuthError("auth-oid4vp/bad-set-options",
132
+ "DCQL: credential_sets[" + si + "].options must be a non-empty array");
133
+ }
134
+ set.options.forEach(function (option, oi) {
135
+ if (!Array.isArray(option) || option.length === 0) {
136
+ throw new AuthError("auth-oid4vp/bad-set-option",
137
+ "DCQL: credential_sets[" + si + "].options[" + oi + "] must be a non-empty array");
138
+ }
139
+ option.forEach(function (id) {
140
+ if (!seenIds.has(id)) {
141
+ throw new AuthError("auth-oid4vp/unknown-set-id",
142
+ "DCQL: credential_sets[" + si + "] references unknown credential id \"" + id + "\"");
143
+ }
144
+ });
145
+ });
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Walk the path against the resolved-claim object the SD-JWT VC
152
+ * verifier produced. Returns { found, value }.
153
+ * path = ["address", "country"] → claims.address.country
154
+ * path = ["array", 0] → claims.array[0]
155
+ * null = "any element" (DCQL §6.4.2 array path semantics) — for
156
+ * v1-defensible we don't dispatch on null; refuse with a clear
157
+ * error so the operator knows the gap.
158
+ */
159
+ function _resolvePath(claims, path) {
160
+ var node = claims;
161
+ for (var i = 0; i < path.length; i++) {
162
+ var seg = path[i];
163
+ if (seg === null) {
164
+ // DCQL §6.4.2: null means "any element of the array at this
165
+ // depth". Not in v1 — refuse loudly so it doesn't silently
166
+ // match nothing.
167
+ throw new AuthError("auth-oid4vp/null-path-segment-not-supported",
168
+ "DCQL: null path segment (any-element) not supported in v1; supply a numeric index");
169
+ }
170
+ if (node === undefined || node === null) return { found: false, value: undefined };
171
+ node = node[seg];
172
+ }
173
+ return { found: node !== undefined, value: node };
174
+ }
175
+
176
+ function _matchClaim(claims, claimQuery) {
177
+ var resolved = _resolvePath(claims, claimQuery.path);
178
+ if (!resolved.found) return false;
179
+ if (claimQuery.values && claimQuery.values.length > 0) {
180
+ return claimQuery.values.some(function (v) {
181
+ return v === resolved.value || JSON.stringify(v) === JSON.stringify(resolved.value);
182
+ });
183
+ }
184
+ return true;
185
+ }
186
+
187
+ function _matchCredentialQuery(presentation, query) {
188
+ // Format match
189
+ if (presentation.format !== query.format) return false;
190
+ // vct match (SD-JWT VC specific meta filter)
191
+ if (query.meta && Array.isArray(query.meta.vct_values)) {
192
+ var vct = presentation.claims && presentation.claims.vct;
193
+ if (!vct || query.meta.vct_values.indexOf(vct) === -1) return false;
194
+ }
195
+ // Issuer match
196
+ if (query.meta && Array.isArray(query.meta.issuer_values)) {
197
+ var iss = presentation.claims && presentation.claims.iss;
198
+ if (!iss || query.meta.issuer_values.indexOf(iss) === -1) return false;
199
+ }
200
+ // Per-claim filters
201
+ if (Array.isArray(query.claims)) {
202
+ for (var i = 0; i < query.claims.length; i++) {
203
+ if (!_matchClaim(presentation.claims, query.claims[i])) return false;
204
+ }
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * @primitive b.auth.oid4vp.matchDcql
211
+ * @signature b.auth.oid4vp.matchDcql(presentations, dcql)
212
+ * @since 0.8.62
213
+ * @status stable
214
+ * @related b.auth.oid4vp.verifier.create
215
+ *
216
+ * Structural matcher: confirms the wallet's selected presentations
217
+ * (each with its DCQL `id` + verified `claims`) satisfy the DCQL
218
+ * query. Returns `{ valid, matched, errors }`. Operators wanting to
219
+ * implement their own verifier transport call this directly after
220
+ * SD-JWT VC verification.
221
+ *
222
+ * @example
223
+ * var match = b.auth.oid4vp.matchDcql([
224
+ * { id: "id-card", format: "vc+sd-jwt", claims: { vct: "...", given_name: "Alice" } }
225
+ * ], dcqlQuery);
226
+ * if (!match.valid) throw new Error(match.errors.join(", "));
227
+ */
228
+ function matchDcql(presentations, dcql) {
229
+ _validateDcql(dcql);
230
+ if (!Array.isArray(presentations)) {
231
+ return { valid: false, matched: {}, errors: ["presentations must be an array"] };
232
+ }
233
+ var byId = {};
234
+ for (var i = 0; i < presentations.length; i++) {
235
+ if (!presentations[i] || typeof presentations[i].id !== "string") {
236
+ return { valid: false, matched: {}, errors: ["presentation[" + i + "] missing id"] };
237
+ }
238
+ byId[presentations[i].id] = presentations[i];
239
+ }
240
+ var matched = {};
241
+ var errors = [];
242
+ // Match every credential_query
243
+ dcql.credentials.forEach(function (cq) {
244
+ var pres = byId[cq.id];
245
+ if (!pres) {
246
+ // Will be enforced by credential_sets if `required` — pure
247
+ // credentials[] without a set is required by default per spec.
248
+ return;
249
+ }
250
+ if (_matchCredentialQuery(pres, cq)) {
251
+ matched[cq.id] = pres;
252
+ } else {
253
+ errors.push("credential '" + cq.id + "' presented but does not satisfy filters");
254
+ }
255
+ });
256
+ // Apply credential_sets — at least one option per set must be
257
+ // satisfied. A set is required by default per spec; `required:
258
+ // false` makes it optional.
259
+ if (Array.isArray(dcql.credential_sets)) {
260
+ dcql.credential_sets.forEach(function (set, si) {
261
+ var optional = set.required === false;
262
+ var ok = set.options.some(function (option) {
263
+ return option.every(function (id) { return matched[id]; });
264
+ });
265
+ if (!ok && !optional) {
266
+ errors.push("credential_set[" + si + "] not satisfied (none of " +
267
+ JSON.stringify(set.options) + " fully matched)");
268
+ }
269
+ });
270
+ } else {
271
+ // No credential_sets — every entry in credentials[] is required.
272
+ dcql.credentials.forEach(function (cq) {
273
+ if (!matched[cq.id]) {
274
+ errors.push("credential '" + cq.id + "' missing from presentation");
275
+ }
276
+ });
277
+ }
278
+ return { valid: errors.length === 0, matched: matched, errors: errors };
279
+ }
280
+
281
+ /**
282
+ * @primitive b.auth.oid4vp.verifier.create
283
+ * @signature b.auth.oid4vp.verifier.create(opts)
284
+ * @since 0.8.62
285
+ * @status stable
286
+ * @related b.auth.oid4vp.matchDcql
287
+ *
288
+ * Build an OID4VP verifier. Returns helpers for emitting an
289
+ * authorization request with a DCQL query and parsing the wallet's
290
+ * signed vp_token response.
291
+ *
292
+ * @opts
293
+ * {
294
+ * clientId: string, // required
295
+ * responseUri: string, // where the wallet POSTs vp_token (RP-side)
296
+ * issuerKeyResolver: fn(header)→keyOrJwk, // resolves SD-JWT VC issuer signing key
297
+ * audience?: string, // override aud claim (defaults to clientId)
298
+ * keyAttestationVerifier?: fn,
299
+ * }
300
+ *
301
+ * @example
302
+ * var verifier = b.auth.oid4vp.verifier.create({
303
+ * clientId: "verifier-1",
304
+ * responseUri: "https://verifier.example/vp",
305
+ * issuerKeyResolver: async function (header) { return jwksByKid[header.kid]; },
306
+ * });
307
+ */
308
+ function create(opts) {
309
+ validateOpts.requireObject(opts, "auth.oid4vp.verifier.create", AuthError);
310
+ validateOpts.requireNonEmptyString(opts.clientId, "verifier.create: clientId", AuthError, "auth-oid4vp/no-client-id");
311
+ validateOpts.requireNonEmptyString(opts.responseUri, "verifier.create: responseUri", AuthError, "auth-oid4vp/no-response-uri");
312
+ if (typeof opts.issuerKeyResolver !== "function") {
313
+ throw new AuthError("auth-oid4vp/no-resolver",
314
+ "verifier.create: issuerKeyResolver is required");
315
+ }
316
+
317
+ var audience = opts.audience || opts.clientId;
318
+
319
+ /**
320
+ * @primitive b.auth.oid4vp.verifier.createRequest
321
+ * @signature b.auth.oid4vp.verifier.createRequest(opts)
322
+ * @since 0.8.62
323
+ *
324
+ * Build the OID4VP authorization request body. Operators sign + post
325
+ * the JWT (via PAR) or render it as an `openid4vp://` deep-link.
326
+ *
327
+ * @opts
328
+ * {
329
+ * dcql: object, // DCQL query — required
330
+ * responseMode?: string, // default "direct_post"
331
+ * nonce?: string,
332
+ * state?: string,
333
+ * aud?: string,
334
+ * }
335
+ *
336
+ * @example
337
+ * var rv = verifier.createRequest({
338
+ * dcql: {
339
+ * credentials: [{ id: "id-card", format: "vc+sd-jwt", claims: [{ path: ["given_name"] }] }],
340
+ * },
341
+ * });
342
+ * // → { request, nonce, state }
343
+ */
344
+ function createRequest(ropts) {
345
+ ropts = ropts || {};
346
+ if (!ropts.dcql) {
347
+ throw new AuthError("auth-oid4vp/no-dcql",
348
+ "createRequest: dcql is required");
349
+ }
350
+ _validateDcql(ropts.dcql);
351
+ var nonce = ropts.nonce || generateToken(16); // allow:raw-byte-literal — 128-bit nonce
352
+ var state = ropts.state || generateToken(16); // allow:raw-byte-literal — 128-bit state
353
+ var request = {
354
+ response_type: "vp_token",
355
+ response_mode: ropts.responseMode || "direct_post",
356
+ client_id: opts.clientId,
357
+ response_uri: opts.responseUri,
358
+ nonce: nonce,
359
+ state: state,
360
+ dcql_query: ropts.dcql,
361
+ };
362
+ if (ropts.aud) request.aud = ropts.aud;
363
+ _emitAudit("request_created", "success", { state: state });
364
+ _emitMetric("request-created");
365
+ return { request: request, nonce: nonce, state: state };
366
+ }
367
+
368
+ /**
369
+ * @primitive b.auth.oid4vp.verifier.verifyResponse
370
+ * @signature b.auth.oid4vp.verifier.verifyResponse(opts)
371
+ * @since 0.8.62
372
+ *
373
+ * Parse the wallet's vp_token response, verify each SD-JWT VC
374
+ * presentation (signature + KB-JWT + nonce + audience), and run
375
+ * the DCQL matcher. Returns
376
+ * { valid, presentations: [{ id, claims, ... }], matched, errors }
377
+ *
378
+ * The vp_token may be a single string OR an array of strings. The
379
+ * legacy Presentation Exchange `presentation_submission` is
380
+ * accepted but not consumed — DCQL is the canonical query path.
381
+ *
382
+ * @opts
383
+ * {
384
+ * vpToken: object|string,
385
+ * dcql: object,
386
+ * nonce: string,
387
+ * requireKeyAttestation?: boolean,
388
+ * }
389
+ *
390
+ * @example
391
+ * var result = await verifier.verifyResponse({
392
+ * vpToken: req.body.vp_token,
393
+ * dcql: storedDcql,
394
+ * nonce: storedNonce,
395
+ * });
396
+ * // → { valid, presentations, matched, errors }
397
+ */
398
+ async function verifyResponse(vopts) {
399
+ vopts = vopts || {};
400
+ if (!vopts.dcql) {
401
+ throw new AuthError("auth-oid4vp/no-dcql",
402
+ "verifyResponse: dcql required");
403
+ }
404
+ if (typeof vopts.nonce !== "string" || vopts.nonce.length === 0) {
405
+ throw new AuthError("auth-oid4vp/no-nonce",
406
+ "verifyResponse: nonce required (must equal the value passed to createRequest)");
407
+ }
408
+ _validateDcql(vopts.dcql);
409
+
410
+ // OID4VP §7.1: vp_token is a JSON object whose keys are DCQL
411
+ // credential_query ids and whose values are credential
412
+ // presentations (string or array of strings). Operators that
413
+ // received the legacy single-string vp_token path through PE 2.0
414
+ // can pass it via `legacyVpToken`; we wrap it as
415
+ // `{ <first-cred-id>: <token> }` for downstream uniformity.
416
+ var vp = vopts.vpToken;
417
+ if (typeof vp === "string") {
418
+ // Legacy single-string vp_token — bind to the first
419
+ // credential_query id. Refuse if there's more than one.
420
+ if (vopts.dcql.credentials.length !== 1) {
421
+ throw new AuthError("auth-oid4vp/legacy-multi-credential",
422
+ "verifyResponse: string vp_token only valid for single-credential queries");
423
+ }
424
+ var k = vopts.dcql.credentials[0].id;
425
+ var tmp = {}; tmp[k] = vp; vp = tmp;
426
+ }
427
+ if (!vp || typeof vp !== "object" || Array.isArray(vp)) {
428
+ throw new AuthError("auth-oid4vp/bad-vp-token",
429
+ "verifyResponse: vp_token must be a JSON object keyed by DCQL credential id");
430
+ }
431
+
432
+ var presentations = [];
433
+ var verifyErrors = [];
434
+ var queriesById = {};
435
+ vopts.dcql.credentials.forEach(function (cq) { queriesById[cq.id] = cq; });
436
+
437
+ var ids = Object.keys(vp);
438
+ for (var i = 0; i < ids.length; i++) {
439
+ var id = ids[i];
440
+ var cq = queriesById[id];
441
+ if (!cq) {
442
+ verifyErrors.push("vp_token contains key '" + id + "' not present in DCQL query");
443
+ continue;
444
+ }
445
+ var token = vp[id];
446
+ // Multiple presentations under one id — verify each.
447
+ var tokens = Array.isArray(token) ? token : [token];
448
+ for (var ti = 0; ti < tokens.length; ti++) {
449
+ var t = tokens[ti];
450
+ if (typeof t !== "string") {
451
+ verifyErrors.push("vp_token['" + id + "'][" + ti + "] is not a string");
452
+ continue;
453
+ }
454
+ try {
455
+ var verified = await sdJwtVcCore().verify(t, {
456
+ issuerKeyResolver: opts.issuerKeyResolver,
457
+ audience: audience,
458
+ nonce: vopts.nonce,
459
+ requireKeyBinding: true,
460
+ requireKeyAttestation: vopts.requireKeyAttestation === true,
461
+ keyAttestationVerifier: opts.keyAttestationVerifier || null,
462
+ expectedVct: cq.meta && cq.meta.vct_values && cq.meta.vct_values.length === 1
463
+ ? cq.meta.vct_values[0] : undefined,
464
+ });
465
+ presentations.push({
466
+ id: id,
467
+ format: cq.format,
468
+ claims: verified.claims,
469
+ issuerHeader: verified.issuerHeader,
470
+ holderKey: verified.holderKey,
471
+ keyAttestationClaims: verified.keyAttestationClaims,
472
+ });
473
+ } catch (e) {
474
+ verifyErrors.push("vp_token['" + id + "'][" + ti + "] verify failed: " +
475
+ ((e && e.message) || String(e)));
476
+ }
477
+ }
478
+ }
479
+
480
+ var matchResult = matchDcql(presentations, vopts.dcql);
481
+
482
+ if (verifyErrors.length > 0 || !matchResult.valid) {
483
+ _emitAudit("verify_failed", "failure", {
484
+ verifyErrors: verifyErrors.length,
485
+ matchErrors: matchResult.errors.length,
486
+ });
487
+ } else {
488
+ _emitAudit("verify_succeeded", "success", {
489
+ presentations: presentations.length,
490
+ });
491
+ }
492
+ _emitMetric(verifyErrors.length === 0 && matchResult.valid ? "verify-succeeded" : "verify-failed");
493
+
494
+ return {
495
+ valid: verifyErrors.length === 0 && matchResult.valid,
496
+ presentations: presentations,
497
+ matched: matchResult.matched,
498
+ errors: verifyErrors.concat(matchResult.errors),
499
+ };
500
+ }
501
+
502
+ return {
503
+ createRequest: createRequest,
504
+ verifyResponse: verifyResponse,
505
+ matchDcql: matchDcql,
506
+ clientId: opts.clientId,
507
+ responseUri: opts.responseUri,
508
+ };
509
+ }
510
+
511
+ module.exports = {
512
+ verifier: { create: create },
513
+ matchDcql: matchDcql,
514
+ };