@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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -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
|
+
};
|