@digitalbazaar/oid4-client 4.3.0 → 4.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.
- package/lib/authorizationRequest.js +344 -0
- package/lib/authorizationResponse.js +312 -0
- package/lib/convert.js +440 -0
- package/lib/index.js +3 -2
- package/lib/oid4vp.js +20 -836
- package/lib/util.js +50 -1
- package/package.json +8 -9
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
assert, assertOptional, createNamedError, fetchJSON, selectJwk
|
|
6
|
+
} from './util.js';
|
|
7
|
+
import {decodeJwt, jwtVerify} from 'jose';
|
|
8
|
+
|
|
9
|
+
// get an authorization request from a verifier
|
|
10
|
+
export async function get({url, getVerificationKey, agent} = {}) {
|
|
11
|
+
try {
|
|
12
|
+
assert(url, 'url', 'string');
|
|
13
|
+
|
|
14
|
+
let authorizationRequest;
|
|
15
|
+
let requestUrl;
|
|
16
|
+
let expectedClientId;
|
|
17
|
+
if(url.startsWith('https://')) {
|
|
18
|
+
// the request must be retrieved via HTTP
|
|
19
|
+
requestUrl = url;
|
|
20
|
+
} else {
|
|
21
|
+
// parse the request from the given URL
|
|
22
|
+
({authorizationRequest} = _parseOID4VPUrl({url}));
|
|
23
|
+
expectedClientId = authorizationRequest.client_id;
|
|
24
|
+
if(authorizationRequest.request_uri) {
|
|
25
|
+
requestUrl = authorizationRequest.request_uri;
|
|
26
|
+
}
|
|
27
|
+
// if whole request is passed by reference, then it MUST be a signed JWT
|
|
28
|
+
if(authorizationRequest.request) {
|
|
29
|
+
authorizationRequest = await _parseJwt({
|
|
30
|
+
jwt: authorizationRequest.request,
|
|
31
|
+
getVerificationKey,
|
|
32
|
+
signatureRequired: true
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// fetch request if necessary...
|
|
38
|
+
let fetched = false;
|
|
39
|
+
let response;
|
|
40
|
+
let jwt;
|
|
41
|
+
if(requestUrl) {
|
|
42
|
+
fetched = true;
|
|
43
|
+
({
|
|
44
|
+
payload: authorizationRequest, response, jwt
|
|
45
|
+
} = await _fetch({requestUrl, getVerificationKey, agent}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ensure authorization request is valid
|
|
49
|
+
validate({authorizationRequest, expectedClientId});
|
|
50
|
+
|
|
51
|
+
// resolve and validate any additional parameters in the request
|
|
52
|
+
authorizationRequest = await resolveParams({authorizationRequest, agent});
|
|
53
|
+
|
|
54
|
+
return {authorizationRequest, fetched, requestUrl, response, jwt};
|
|
55
|
+
} catch(cause) {
|
|
56
|
+
const message = cause.data?.error_description ?? cause.message;
|
|
57
|
+
throw createNamedError({
|
|
58
|
+
message: `Could not get authorization request: ${message}`,
|
|
59
|
+
name: 'OperationError',
|
|
60
|
+
cause
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requestsFormat({authorizationRequest, format} = {}) {
|
|
66
|
+
/* e.g. presentation definition requesting an mdoc:
|
|
67
|
+
{
|
|
68
|
+
id: 'mdl-test-age-over-21',
|
|
69
|
+
input_descriptors: [{
|
|
70
|
+
id: 'org.iso.18013.5.1.mDL',
|
|
71
|
+
format: {
|
|
72
|
+
mso_mdoc: {
|
|
73
|
+
alg: ['ES256']
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}]
|
|
77
|
+
}
|
|
78
|
+
*/
|
|
79
|
+
return authorizationRequest.presentation_definition?.input_descriptors?.some(
|
|
80
|
+
e => e?.format?.[format]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// FIXME: in a major release, remove support for params requiring resolution
|
|
84
|
+
export async function resolveParams({authorizationRequest, agent}) {
|
|
85
|
+
const {
|
|
86
|
+
client_metadata_uri,
|
|
87
|
+
presentation_definition_uri,
|
|
88
|
+
...resolved
|
|
89
|
+
} = {...authorizationRequest};
|
|
90
|
+
|
|
91
|
+
// get client meta data from URL if specified
|
|
92
|
+
if(client_metadata_uri) {
|
|
93
|
+
const response = await fetchJSON({url: client_metadata_uri, agent});
|
|
94
|
+
if(!response.data) {
|
|
95
|
+
throw createNamedError({
|
|
96
|
+
message: 'Client meta data format is not JSON.',
|
|
97
|
+
name: 'DataError'
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
resolved.client_metadata = response.data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// get presentation definition from URL if not embedded
|
|
104
|
+
if(presentation_definition_uri) {
|
|
105
|
+
const response = await fetchJSON(
|
|
106
|
+
{url: presentation_definition_uri, agent});
|
|
107
|
+
if(!response.data) {
|
|
108
|
+
throw createNamedError({
|
|
109
|
+
message: 'Presentation definition format is not JSON.',
|
|
110
|
+
name: 'DataError'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
resolved.presentation_definition = response.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
assert(resolved.presentation_definition, 'presentation_definition', 'object');
|
|
117
|
+
assert(
|
|
118
|
+
resolved.presentation_definition?.id,
|
|
119
|
+
'presentation_definition.id', 'string');
|
|
120
|
+
|
|
121
|
+
// FIXME: further validate `authorizationRequest.presentation_definition`
|
|
122
|
+
// FIXME: further validate `authorizationRequest.client_metadata`
|
|
123
|
+
|
|
124
|
+
// `direct_post.jwt` response mode requires encryption; ensure the client
|
|
125
|
+
// meta data has the necessary parameters
|
|
126
|
+
if(resolved.response_mode === 'direct_post.jwt') {
|
|
127
|
+
const {
|
|
128
|
+
authorization_encrypted_response_alg = 'ECDH-ES',
|
|
129
|
+
authorization_encrypted_response_enc = 'A256GCM',
|
|
130
|
+
jwks
|
|
131
|
+
} = resolved.client_metadata;
|
|
132
|
+
if(authorization_encrypted_response_alg !== 'ECDH-ES') {
|
|
133
|
+
throw createNamedError({
|
|
134
|
+
message: `"${authorization_encrypted_response_alg}" is not ` +
|
|
135
|
+
'supported; only "ECDH-ES" is supported.',
|
|
136
|
+
name: 'NotSupportedError'
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if(authorization_encrypted_response_enc !== 'A256GCM') {
|
|
140
|
+
throw createNamedError({
|
|
141
|
+
message: `"${authorization_encrypted_response_enc}" is not ` +
|
|
142
|
+
'supported; only "A256GCM" is supported.',
|
|
143
|
+
name: 'NotSupportedError'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if(!selectJwk({
|
|
147
|
+
keys: jwks?.keys, alg: 'ECDH-ES', kty: 'EC', crv: 'P-256', use: 'enc'
|
|
148
|
+
})) {
|
|
149
|
+
throw createNamedError({
|
|
150
|
+
message: 'No matching key found for "ECDH-ES" in client meta data ' +
|
|
151
|
+
'JWK key set.',
|
|
152
|
+
name: 'NotFoundError'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return resolved;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function usesClientIdScheme({authorizationRequest, scheme} = {}) {
|
|
161
|
+
return authorizationRequest?.client_id_scheme === scheme ||
|
|
162
|
+
authorizationRequest?.client_id?.startsWith(`${scheme}:`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function validate({authorizationRequest, expectedClientId}) {
|
|
166
|
+
// validate payload (expected authorization request)
|
|
167
|
+
const {
|
|
168
|
+
client_id,
|
|
169
|
+
client_id_scheme,
|
|
170
|
+
client_metadata,
|
|
171
|
+
client_metadata_uri,
|
|
172
|
+
nonce,
|
|
173
|
+
presentation_definition,
|
|
174
|
+
presentation_definition_uri,
|
|
175
|
+
response_mode,
|
|
176
|
+
scope
|
|
177
|
+
} = authorizationRequest;
|
|
178
|
+
assert(client_id, 'client_id', 'string');
|
|
179
|
+
// ensure `client_id` matches expected client ID
|
|
180
|
+
if(expectedClientId !== undefined && client_id !== expectedClientId) {
|
|
181
|
+
throw createNamedError({
|
|
182
|
+
message: '"client_id" in fetched request does not match authorization ' +
|
|
183
|
+
'request URL parameter.',
|
|
184
|
+
name: 'DataError'
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
assert(nonce, 'nonce', 'string');
|
|
188
|
+
assertOptional(client_id_scheme, 'client_id_scheme', 'string');
|
|
189
|
+
assertOptional(client_metadata, 'client_metadata', 'object');
|
|
190
|
+
// FIXME: remove `client_metadata_uri` in a future revision, it is not
|
|
191
|
+
// supported in the latest OID4VP, bad practice, and rarely used
|
|
192
|
+
assertOptional(client_metadata_uri, 'client_metadata_uri', 'string');
|
|
193
|
+
assertOptional(
|
|
194
|
+
presentation_definition, 'presentation_definition', 'object');
|
|
195
|
+
// FIXME: remove `presentation_definition_uri` in a future revision, it is
|
|
196
|
+
// not supported in the latest OID4VP, bad practice, and rarely used
|
|
197
|
+
assertOptional(
|
|
198
|
+
presentation_definition_uri, 'presentation_definition_uri', 'string');
|
|
199
|
+
assertOptional(response_mode, 'response_mode', 'string');
|
|
200
|
+
assertOptional(scope, 'scope', 'string');
|
|
201
|
+
if(client_metadata && client_metadata_uri) {
|
|
202
|
+
throw createNamedError({
|
|
203
|
+
message: 'Only one of "client_metadata" and ' +
|
|
204
|
+
'"client_metadata_uri" must be present.',
|
|
205
|
+
name: 'DataError'
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if(presentation_definition && presentation_definition_uri) {
|
|
209
|
+
throw createNamedError({
|
|
210
|
+
message: 'Only one of "presentation_definition" and ' +
|
|
211
|
+
'"presentation_definition_uri" must be present.',
|
|
212
|
+
name: 'DataError'
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// Note: This implementation requires `response_mode` to be `direct_post`
|
|
216
|
+
// or `direct_post.jwt`; no other modes are supported.
|
|
217
|
+
if(!(response_mode === 'direct_post' ||
|
|
218
|
+
response_mode === 'direct_post.jwt')) {
|
|
219
|
+
throw createNamedError({
|
|
220
|
+
message: 'Only "direct_post" and "direct_post.jwt" ' +
|
|
221
|
+
'response modes are supported.',
|
|
222
|
+
name: 'NotSupportedError'
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function _fetch({requestUrl, getVerificationKey, agent}) {
|
|
228
|
+
// FIXME: every `fetchJSON` call needs to use a block list or other
|
|
229
|
+
// protections to prevent a confused deputy attack where the `requestUrl`
|
|
230
|
+
// accesses a location it should not, e.g., a URL `localhost` is used when
|
|
231
|
+
// it shouldn't be
|
|
232
|
+
const response = await fetchJSON({url: requestUrl, agent});
|
|
233
|
+
|
|
234
|
+
// parse payload from response data...
|
|
235
|
+
const contentType = response.headers.get('content-type');
|
|
236
|
+
const jwt = await response.text();
|
|
237
|
+
|
|
238
|
+
// verify response is a JWT-secured authorization request
|
|
239
|
+
if(!(contentType.includes('application/oauth-authz-req+jwt') &&
|
|
240
|
+
typeof jwt === 'string')) {
|
|
241
|
+
throw createNamedError({
|
|
242
|
+
message: 'Authorization request content-type must be ' +
|
|
243
|
+
'"application/oauth-authz-req+jwt".',
|
|
244
|
+
name: 'DataError'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// return parsed payload and original response
|
|
249
|
+
const payload = await _parseJwt({jwt, getVerificationKey});
|
|
250
|
+
return {payload, response, jwt};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _get(sp, name) {
|
|
254
|
+
const value = sp.get(name);
|
|
255
|
+
return value === null ? undefined : value;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function _parseJwt({jwt, getVerificationKey, signatureRequired}) {
|
|
259
|
+
const payload = decodeJwt(jwt);
|
|
260
|
+
|
|
261
|
+
// check if a signature on the JWT is required:
|
|
262
|
+
// - `client_metadata.require_signed_request_object` is `true`, OR
|
|
263
|
+
// - `client_id_scheme` requires the JWT to be signed
|
|
264
|
+
signatureRequired = signatureRequired ||
|
|
265
|
+
payload.client_metadata?.require_signed_request_object === true ||
|
|
266
|
+
usesClientIdScheme({authorizationRequest: payload, scheme: 'x509_san_dns'});
|
|
267
|
+
if(!signatureRequired) {
|
|
268
|
+
// no signature required, just use the decoded payload
|
|
269
|
+
return payload;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// create callback function to handle key lookup; `getVerificationKey` may
|
|
273
|
+
// return a promise
|
|
274
|
+
const getKey = protectedHeader => {
|
|
275
|
+
// FIXME: add parser for `x5c`?
|
|
276
|
+
if(getVerificationKey) {
|
|
277
|
+
return getVerificationKey({protectedHeader});
|
|
278
|
+
}
|
|
279
|
+
_throwKeyNotFound(protectedHeader);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// verify the JWT
|
|
283
|
+
const verifyResult = await jwtVerify(jwt, getKey, {alg: 'ES256'});
|
|
284
|
+
return verifyResult.payload;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function _parseOID4VPUrl({url}) {
|
|
288
|
+
const {searchParams} = new URL(url);
|
|
289
|
+
const request = _get(searchParams, 'request');
|
|
290
|
+
const request_uri = _get(searchParams, 'request_uri');
|
|
291
|
+
const response_type = _get(searchParams, 'response_type');
|
|
292
|
+
const response_mode = _get(searchParams, 'response_mode');
|
|
293
|
+
const presentation_definition = _get(
|
|
294
|
+
searchParams, 'presentation_definition');
|
|
295
|
+
const presentation_definition_uri = _get(
|
|
296
|
+
searchParams, 'presentation_definition_uri');
|
|
297
|
+
const client_id = _get(searchParams, 'client_id');
|
|
298
|
+
const client_id_scheme = _get(searchParams, 'client_id_scheme');
|
|
299
|
+
const client_metadata = _get(searchParams, 'client_metadata');
|
|
300
|
+
const nonce = _get(searchParams, 'nonce');
|
|
301
|
+
const response_uri = _get(searchParams, 'response_uri');
|
|
302
|
+
const state = _get(searchParams, 'state');
|
|
303
|
+
if(request && request_uri) {
|
|
304
|
+
const error = createNamedError({
|
|
305
|
+
message: 'Only one of "request" and "request_uri" may be present.',
|
|
306
|
+
name: 'DataError'
|
|
307
|
+
});
|
|
308
|
+
error.url = url;
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
if(!(request || request_uri)) {
|
|
312
|
+
if(response_type !== 'vp_token') {
|
|
313
|
+
throw new Error(`Unsupported "response_type", "${response_type}".`);
|
|
314
|
+
}
|
|
315
|
+
if(!(response_mode === 'direct_post' ||
|
|
316
|
+
response_mode === 'direct_post.jwt')) {
|
|
317
|
+
throw new Error(`Unsupported "response_type", "${response_type}".`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const authorizationRequest = {
|
|
321
|
+
request,
|
|
322
|
+
request_uri,
|
|
323
|
+
response_type,
|
|
324
|
+
response_mode,
|
|
325
|
+
presentation_definition: presentation_definition &&
|
|
326
|
+
JSON.parse(presentation_definition),
|
|
327
|
+
presentation_definition_uri,
|
|
328
|
+
client_id,
|
|
329
|
+
client_id_scheme,
|
|
330
|
+
client_metadata: client_metadata && JSON.parse(client_metadata),
|
|
331
|
+
response_uri,
|
|
332
|
+
nonce,
|
|
333
|
+
state
|
|
334
|
+
};
|
|
335
|
+
return {authorizationRequest};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function _throwKeyNotFound(protectedHeader) {
|
|
339
|
+
const error = new Error(
|
|
340
|
+
'Could not verify signed authorization request; ' +
|
|
341
|
+
`public key "${protectedHeader.kid}" not found.`);
|
|
342
|
+
error.name = 'NotFoundError';
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {createNamedError, selectJwk} from './util.js';
|
|
5
|
+
import {EncryptJWT} from 'jose';
|
|
6
|
+
import {httpClient} from '@digitalbazaar/http-client';
|
|
7
|
+
import jsonpointer from 'jsonpointer';
|
|
8
|
+
import {pathsToVerifiableCredentialPointers} from './convert.js';
|
|
9
|
+
|
|
10
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
export async function send({
|
|
13
|
+
verifiablePresentation,
|
|
14
|
+
presentationSubmission,
|
|
15
|
+
authorizationRequest,
|
|
16
|
+
vpToken,
|
|
17
|
+
encryptionOptions = {},
|
|
18
|
+
agent
|
|
19
|
+
} = {}) {
|
|
20
|
+
try {
|
|
21
|
+
if(!(verifiablePresentation || vpToken)) {
|
|
22
|
+
throw createNamedError({
|
|
23
|
+
message: 'One of "verifiablePresentation" or "vpToken" must be given.',
|
|
24
|
+
name: 'DataError'
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// if no `vpToken` given, use VP
|
|
28
|
+
vpToken = vpToken ?? JSON.stringify(verifiablePresentation);
|
|
29
|
+
|
|
30
|
+
// if no `presentationSubmission` provided, auto-generate one
|
|
31
|
+
let generatedPresentationSubmission = false;
|
|
32
|
+
if(!presentationSubmission) {
|
|
33
|
+
({presentationSubmission} = createPresentationSubmission({
|
|
34
|
+
presentationDefinition: authorizationRequest.presentation_definition,
|
|
35
|
+
verifiablePresentation
|
|
36
|
+
}));
|
|
37
|
+
generatedPresentationSubmission = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// prepare response body
|
|
41
|
+
const body = new URLSearchParams();
|
|
42
|
+
|
|
43
|
+
// if `authorizationRequest.response_mode` is `direct.jwt` generate a JWT
|
|
44
|
+
if(authorizationRequest.response_mode === 'direct_post.jwt') {
|
|
45
|
+
const jwt = await _encrypt({
|
|
46
|
+
vpToken, presentationSubmission, authorizationRequest,
|
|
47
|
+
encryptionOptions
|
|
48
|
+
});
|
|
49
|
+
body.set('response', jwt);
|
|
50
|
+
} else {
|
|
51
|
+
// include vp token and presentation submittion directly in body
|
|
52
|
+
body.set('vp_token', vpToken);
|
|
53
|
+
body.set(
|
|
54
|
+
'presentation_submission', JSON.stringify(presentationSubmission));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// send response
|
|
58
|
+
const response = await httpClient.post(authorizationRequest.response_uri, {
|
|
59
|
+
agent, body, headers: {accept: 'application/json'},
|
|
60
|
+
// FIXME: limit response size
|
|
61
|
+
// timeout in ms for response
|
|
62
|
+
timeout: 5000
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// return response data as `result`
|
|
66
|
+
const result = response.data || {};
|
|
67
|
+
if(generatedPresentationSubmission) {
|
|
68
|
+
// return any generated presentation submission
|
|
69
|
+
return {result, presentationSubmission};
|
|
70
|
+
}
|
|
71
|
+
return {result};
|
|
72
|
+
} catch(cause) {
|
|
73
|
+
const message = cause.data?.error_description ?? cause.message;
|
|
74
|
+
const error = new Error(
|
|
75
|
+
`Could not send OID4VP authorization response: ${message}.`,
|
|
76
|
+
{cause});
|
|
77
|
+
error.name = 'OperationError';
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// creates a "presentation submission" from a presentation definition and VP
|
|
83
|
+
export function createPresentationSubmission({
|
|
84
|
+
presentationDefinition, verifiablePresentation
|
|
85
|
+
} = {}) {
|
|
86
|
+
const descriptor_map = [];
|
|
87
|
+
const presentationSubmission = {
|
|
88
|
+
id: crypto.randomUUID(),
|
|
89
|
+
definition_id: presentationDefinition.id,
|
|
90
|
+
descriptor_map
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// walk through each input descriptor object and match it to a VC
|
|
95
|
+
let {verifiableCredential: vcs} = verifiablePresentation;
|
|
96
|
+
const single = !Array.isArray(vcs);
|
|
97
|
+
if(single) {
|
|
98
|
+
vcs = [vcs];
|
|
99
|
+
}
|
|
100
|
+
/* Note: It is conceivable that the same VC could match multiple input
|
|
101
|
+
descriptors. In this simplistic implementation, the first VC that matches
|
|
102
|
+
is used. This may result in VCs in the VP not being mapped to an input
|
|
103
|
+
descriptor, but every input descriptor having a VC that matches (i.e., at
|
|
104
|
+
least one VC will be shared across multiple input descriptors). If
|
|
105
|
+
some other behavior is more desirable, this can be changed in a future
|
|
106
|
+
version. */
|
|
107
|
+
for(const inputDescriptor of presentationDefinition.input_descriptors) {
|
|
108
|
+
// walk through each VC and try to match it to the input descriptor
|
|
109
|
+
for(let i = 0; i < vcs.length; ++i) {
|
|
110
|
+
const verifiableCredential = vcs[i];
|
|
111
|
+
if(_matchesInputDescriptor({inputDescriptor, verifiableCredential})) {
|
|
112
|
+
descriptor_map.push({
|
|
113
|
+
id: inputDescriptor.id,
|
|
114
|
+
path: '$',
|
|
115
|
+
format: 'ldp_vp',
|
|
116
|
+
path_nested: {
|
|
117
|
+
format: 'ldp_vc',
|
|
118
|
+
path: single ?
|
|
119
|
+
'$.verifiableCredential' :
|
|
120
|
+
'$.verifiableCredential[' + i + ']'
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch(cause) {
|
|
128
|
+
throw createNamedError({
|
|
129
|
+
message: `Could not create presentation submission: ${cause.message}`,
|
|
130
|
+
name: 'OperationError',
|
|
131
|
+
cause
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {presentationSubmission};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function _encrypt({
|
|
139
|
+
vpToken, presentationSubmission, authorizationRequest, encryptionOptions
|
|
140
|
+
}) {
|
|
141
|
+
// get recipient public JWK from client_metadata JWK key set
|
|
142
|
+
const jwks = authorizationRequest?.client_metadata?.jwks;
|
|
143
|
+
const recipientPublicJwk = selectJwk({
|
|
144
|
+
keys: jwks?.keys, alg: 'ECDH-ES', kty: 'EC', crv: 'P-256', use: 'enc'
|
|
145
|
+
});
|
|
146
|
+
if(!recipientPublicJwk) {
|
|
147
|
+
throw createNamedError({
|
|
148
|
+
message: 'No matching key found for "ECDH-ES" in client meta data ' +
|
|
149
|
+
'JWK key set.',
|
|
150
|
+
name: 'NotFoundError'
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// configure `keyManagementParameters` for `EncryptJWT` API
|
|
155
|
+
const keyManagementParameters = {};
|
|
156
|
+
if(encryptionOptions?.mdoc?.sessionTranscript) {
|
|
157
|
+
// ISO 18013-7: include specific session transcript params as apu + apv
|
|
158
|
+
const {
|
|
159
|
+
mdocGeneratedNonce,
|
|
160
|
+
// default to using `authorizationRequest.nonce` for verifier nonce
|
|
161
|
+
verifierGeneratedNonce = authorizationRequest.nonce
|
|
162
|
+
} = encryptionOptions.mdoc.sessionTranscript;
|
|
163
|
+
// note: `EncryptJWT` API requires `apu/apv` (`partyInfoU`/`partyInfoV`)
|
|
164
|
+
// to be passed as Uint8Arrays; they will be encoded using `base64url` by
|
|
165
|
+
// that API
|
|
166
|
+
keyManagementParameters.apu = TEXT_ENCODER.encode(mdocGeneratedNonce);
|
|
167
|
+
keyManagementParameters.apv = TEXT_ENCODER.encode(verifierGeneratedNonce);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const claimSet = {
|
|
171
|
+
vp_token: vpToken,
|
|
172
|
+
presentation_submission: presentationSubmission
|
|
173
|
+
};
|
|
174
|
+
const jwt = await new EncryptJWT(claimSet)
|
|
175
|
+
.setProtectedHeader({
|
|
176
|
+
alg: 'ECDH-ES', enc: 'A256GCM',
|
|
177
|
+
kid: recipientPublicJwk.kid
|
|
178
|
+
})
|
|
179
|
+
.setKeyManagementParameters(keyManagementParameters)
|
|
180
|
+
.encrypt(recipientPublicJwk);
|
|
181
|
+
return jwt;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _filterToValue({filter, strict = false}) {
|
|
185
|
+
/* Each `filter` has a JSON Schema object. In recognition of the fact that
|
|
186
|
+
a query must be usable by common database engines (including perhaps
|
|
187
|
+
encrypted cloud databases) and of the fact that each JSON Schema object will
|
|
188
|
+
come from an untrusted source (and could have malicious regexes, etc.), only
|
|
189
|
+
simple JSON Schema types are supported:
|
|
190
|
+
|
|
191
|
+
`string`: with `const` or `enum`, `format` is not supported and `pattern` has
|
|
192
|
+
partial support as it will be treated as a simple string not a regex; regex
|
|
193
|
+
is a DoS attack vector
|
|
194
|
+
|
|
195
|
+
`array`: with `contains` where uses a `string` filter
|
|
196
|
+
|
|
197
|
+
`allOf`: supported only with the above schemas present in it.
|
|
198
|
+
|
|
199
|
+
*/
|
|
200
|
+
let value;
|
|
201
|
+
|
|
202
|
+
const {type} = filter;
|
|
203
|
+
if(type === 'array') {
|
|
204
|
+
if(filter.contains) {
|
|
205
|
+
if(Array.isArray(filter.contains)) {
|
|
206
|
+
return filter.contains.map(filter => _filterToValue({filter, strict}));
|
|
207
|
+
}
|
|
208
|
+
return _filterToValue({filter: filter.contains, strict});
|
|
209
|
+
}
|
|
210
|
+
if(Array.isArray(filter.allOf) && filter.allOf.every(f => f.contains)) {
|
|
211
|
+
return filter.allOf.map(
|
|
212
|
+
f => _filterToValue({filter: f.contains, strict}));
|
|
213
|
+
}
|
|
214
|
+
if(strict) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'Unsupported filter; array filters must use "allOf" and/or ' +
|
|
217
|
+
'"contains" with a string filter.');
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
if(type === 'string' || type === undefined) {
|
|
222
|
+
if(filter.const !== undefined) {
|
|
223
|
+
value = filter.const;
|
|
224
|
+
} else if(filter.pattern) {
|
|
225
|
+
value = filter.pattern;
|
|
226
|
+
} else if(filter.enum) {
|
|
227
|
+
value = filter.enum.slice();
|
|
228
|
+
} else if(strict) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'Unsupported filter; string filters must use "const" or "pattern".');
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
if(strict) {
|
|
235
|
+
throw new Error(`Unsupported filter type "${type}".`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _matchesInputDescriptor({
|
|
240
|
+
inputDescriptor, verifiableCredential, strict = false
|
|
241
|
+
}) {
|
|
242
|
+
// walk through each field ensuring there is a matching value
|
|
243
|
+
const fields = inputDescriptor?.constraints?.fields || [];
|
|
244
|
+
for(const field of fields) {
|
|
245
|
+
const {path, filter, optional} = field;
|
|
246
|
+
if(optional) {
|
|
247
|
+
// skip field, it is optional
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
// each field must have a `path` (which can be a string or an array)
|
|
253
|
+
if(!(Array.isArray(path) || typeof path === 'string')) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
'Input descriptor field "path" must be a string or array.');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// process any filter
|
|
259
|
+
let value = '';
|
|
260
|
+
if(filter !== undefined) {
|
|
261
|
+
value = _filterToValue({filter, strict});
|
|
262
|
+
}
|
|
263
|
+
// no value to match, presume no match
|
|
264
|
+
if(value === undefined) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
// normalize value to array
|
|
268
|
+
if(!Array.isArray(value)) {
|
|
269
|
+
value = [value];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// get JSON pointers for every path inside a verifiable credential
|
|
273
|
+
const pointers = pathsToVerifiableCredentialPointers({paths: path});
|
|
274
|
+
|
|
275
|
+
// check for a value at at least one path
|
|
276
|
+
for(const pointer of pointers) {
|
|
277
|
+
const existing = jsonpointer.get(verifiableCredential, pointer);
|
|
278
|
+
if(existing === undefined) {
|
|
279
|
+
// VC does not match
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
// look for at least one matching value in `existing`
|
|
283
|
+
let match = false;
|
|
284
|
+
for(const v of value) {
|
|
285
|
+
if(Array.isArray(existing)) {
|
|
286
|
+
if(existing.includes(v)) {
|
|
287
|
+
match = true;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
} else if(existing === v) {
|
|
291
|
+
match = true;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if(!match) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch(cause) {
|
|
300
|
+
const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
|
|
301
|
+
const error = createNamedError({
|
|
302
|
+
message: `Could not process input descriptor field: "${id}".`,
|
|
303
|
+
name: 'DataError',
|
|
304
|
+
cause
|
|
305
|
+
});
|
|
306
|
+
error.field = field;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return true;
|
|
312
|
+
}
|