@digitalbazaar/oid4-client 5.7.2 → 5.9.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/convert/index.js +9 -2
- package/lib/index.js +1 -1
- package/lib/oid4vp/authorizationRequest.js +48 -11
- package/lib/oid4vp/authorizationResponse.js +122 -50
- package/lib/oid4vp/hpke.js +116 -0
- package/lib/oid4vp/index.js +2 -1
- package/lib/oid4vp/jwt.js +66 -0
- package/lib/oid4vp/mdl.js +163 -0
- package/lib/oid4vp/verifier.js +87 -64
- package/lib/query/presentationExchange.js +10 -0
- package/lib/util.js +61 -4
- package/package.json +3 -1
package/lib/convert/index.js
CHANGED
|
@@ -139,8 +139,10 @@ export function toVpr({authorizationRequest, strict = false} = {}) {
|
|
|
139
139
|
client_id,
|
|
140
140
|
client_metadata,
|
|
141
141
|
dcql_query,
|
|
142
|
+
expected_origins,
|
|
142
143
|
nonce,
|
|
143
144
|
presentation_definition,
|
|
145
|
+
response_mode,
|
|
144
146
|
response_uri
|
|
145
147
|
} = authorizationRequest;
|
|
146
148
|
|
|
@@ -186,9 +188,14 @@ export function toVpr({authorizationRequest, strict = false} = {}) {
|
|
|
186
188
|
verifiablePresentationRequest.query = [didAuthnQuery];
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
// map `
|
|
191
|
+
// map `expected_origins` or `response_uri` to `domain`
|
|
190
192
|
if(response_uri !== undefined || client_id !== undefined) {
|
|
191
|
-
|
|
193
|
+
if(response_mode?.startsWith('dc_api') && !response_uri &&
|
|
194
|
+
expected_origins?.length > 0) {
|
|
195
|
+
verifiablePresentationRequest.domain = expected_origins[0];
|
|
196
|
+
} else {
|
|
197
|
+
verifiablePresentationRequest.domain = response_uri;
|
|
198
|
+
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
// map `nonce` to `challenge`
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2023-
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
4
|
import {
|
|
5
5
|
assert, assertOptional, base64Encode,
|
|
@@ -23,13 +23,15 @@ const SUPPORTED_CLIENT_ID_SCHEMES = new Set([
|
|
|
23
23
|
|
|
24
24
|
// get an authorization request from a verifier
|
|
25
25
|
export async function get({
|
|
26
|
-
url, getTrustedCertificates, getVerificationKey, agent
|
|
26
|
+
url, getTrustedCertificates, getVerificationKey, getPostBody, agent,
|
|
27
|
+
overrideMethod
|
|
27
28
|
} = {}) {
|
|
28
29
|
try {
|
|
29
30
|
assert(url, 'url', 'string');
|
|
30
31
|
|
|
31
32
|
let authorizationRequest;
|
|
32
33
|
let requestUrl;
|
|
34
|
+
let requestUrlMethod = 'get';
|
|
33
35
|
let expectedClientId;
|
|
34
36
|
if(url.startsWith('https://')) {
|
|
35
37
|
// the request must be retrieved via HTTP
|
|
@@ -40,6 +42,10 @@ export async function get({
|
|
|
40
42
|
expectedClientId = authorizationRequest.client_id;
|
|
41
43
|
if(authorizationRequest.request_uri) {
|
|
42
44
|
requestUrl = authorizationRequest.request_uri;
|
|
45
|
+
if(authorizationRequest.request_uri_method === 'post' ||
|
|
46
|
+
authorizationRequest.request_uri_method === 'get') {
|
|
47
|
+
requestUrlMethod = authorizationRequest.request_uri_method;
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
50
|
// if whole request is passed by reference, then it MUST be a signed JWT
|
|
45
51
|
if(authorizationRequest.request) {
|
|
@@ -52,6 +58,11 @@ export async function get({
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// allow method override
|
|
62
|
+
if(overrideMethod) {
|
|
63
|
+
requestUrlMethod = overrideMethod;
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
// fetch request if necessary...
|
|
56
67
|
let fetched = false;
|
|
57
68
|
let response;
|
|
@@ -60,8 +71,9 @@ export async function get({
|
|
|
60
71
|
fetched = true;
|
|
61
72
|
({
|
|
62
73
|
payload: authorizationRequest, response, jwt
|
|
63
|
-
} = await
|
|
64
|
-
|
|
74
|
+
} = await _fetchAuthzRequest({
|
|
75
|
+
method: requestUrlMethod, requestUrl, getPostBody,
|
|
76
|
+
getTrustedCertificates, getVerificationKey, agent
|
|
65
77
|
}));
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -85,6 +97,17 @@ export function getClientIdScheme({authorizationRequest} = {}) {
|
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
export function requestsFormat({authorizationRequest, format} = {}) {
|
|
100
|
+
/* e.g. DCQL requesting an mdoc:
|
|
101
|
+
{
|
|
102
|
+
credentials: [{
|
|
103
|
+
id: 'mdl-id',
|
|
104
|
+
format: 'mso_mdoc',
|
|
105
|
+
meta: {
|
|
106
|
+
doctype_value: 'org.iso.18013.5.1.mDL'
|
|
107
|
+
}
|
|
108
|
+
}]
|
|
109
|
+
}
|
|
110
|
+
*/
|
|
88
111
|
/* e.g. presentation definition requesting an mdoc:
|
|
89
112
|
{
|
|
90
113
|
id: 'mdl-test-age-over-21',
|
|
@@ -98,8 +121,13 @@ export function requestsFormat({authorizationRequest, format} = {}) {
|
|
|
98
121
|
}]
|
|
99
122
|
}
|
|
100
123
|
*/
|
|
101
|
-
return
|
|
102
|
-
|
|
124
|
+
return (
|
|
125
|
+
// DCQL
|
|
126
|
+
authorizationRequest.dcql_query?.credentials?.some(
|
|
127
|
+
e => e?.format === format) ||
|
|
128
|
+
// PE
|
|
129
|
+
authorizationRequest.presentation_definition?.input_descriptors?.some(
|
|
130
|
+
e => e?.format?.[format]));
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
export function usesClientIdScheme({authorizationRequest, scheme} = {}) {
|
|
@@ -252,8 +280,8 @@ async function _checkClientIdSchemeRequirements({
|
|
|
252
280
|
}
|
|
253
281
|
|
|
254
282
|
let {client_id: clientId} = authorizationRequest;
|
|
255
|
-
clientId = clientId
|
|
256
|
-
clientId.slice(clientIdScheme.length +
|
|
283
|
+
clientId = clientId?.startsWith(`${clientIdScheme}:`) ?
|
|
284
|
+
clientId.slice(clientIdScheme.length + 1) : clientId;
|
|
257
285
|
|
|
258
286
|
if(clientIdScheme === 'x509_san_dns') {
|
|
259
287
|
// `x509_san_dns` requires leaf cert to have a dNSName ("domain" type) in
|
|
@@ -308,14 +336,21 @@ async function _checkClientIdSchemeRequirements({
|
|
|
308
336
|
}
|
|
309
337
|
}
|
|
310
338
|
|
|
311
|
-
async function
|
|
312
|
-
|
|
339
|
+
async function _fetchAuthzRequest({
|
|
340
|
+
method = 'get', requestUrl, getPostBody,
|
|
341
|
+
getTrustedCertificates, getVerificationKey, agent
|
|
313
342
|
}) {
|
|
314
343
|
// FIXME: every `fetchJSON` call needs to use a block list or other
|
|
315
344
|
// protections to prevent a confused deputy attack where the `requestUrl`
|
|
316
345
|
// accesses a location it should not, e.g., a URL `localhost` is used when
|
|
317
346
|
// it shouldn't be
|
|
318
|
-
const
|
|
347
|
+
const options = {url: requestUrl, agent};
|
|
348
|
+
if(method === 'post') {
|
|
349
|
+
const {body} = await getPostBody?.() ?? new URLSearchParams();
|
|
350
|
+
options.method = 'post';
|
|
351
|
+
options.body = body ?? new URLSearchParams();
|
|
352
|
+
}
|
|
353
|
+
const response = await fetchJSON(options);
|
|
319
354
|
|
|
320
355
|
// parse payload from response data...
|
|
321
356
|
const contentType = response.headers.get('content-type');
|
|
@@ -406,6 +441,7 @@ function _parseOID4VPUrl({url}) {
|
|
|
406
441
|
const {searchParams} = new URL(url);
|
|
407
442
|
const request = _get(searchParams, 'request');
|
|
408
443
|
const request_uri = _get(searchParams, 'request_uri');
|
|
444
|
+
const request_uri_method = _get(searchParams, 'request_uri_method');
|
|
409
445
|
const response_type = _get(searchParams, 'response_type');
|
|
410
446
|
const response_mode = _get(searchParams, 'response_mode');
|
|
411
447
|
const presentation_definition = _get(
|
|
@@ -436,6 +472,7 @@ function _parseOID4VPUrl({url}) {
|
|
|
436
472
|
const authorizationRequest = {
|
|
437
473
|
request,
|
|
438
474
|
request_uri,
|
|
475
|
+
request_uri_method,
|
|
439
476
|
response_type,
|
|
440
477
|
response_mode,
|
|
441
478
|
presentation_definition: presentation_definition &&
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2023-
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc.
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: BSD-3-Clause
|
|
3
5
|
*/
|
|
6
|
+
import * as base64url from 'base64url-universal';
|
|
4
7
|
import {createNamedError, selectJwk} from '../util.js';
|
|
5
|
-
import {
|
|
8
|
+
import {encode as cborEncode} from 'cborg';
|
|
9
|
+
import {encodeSessionTranscript} from './mdl.js';
|
|
10
|
+
import {encrypt as hpkeEncrypt} from './hpke.js';
|
|
6
11
|
import {httpClient} from '@digitalbazaar/http-client';
|
|
12
|
+
import {encrypt as jwtEncrypt} from './jwt.js';
|
|
7
13
|
import {pathsToVerifiableCredentialPointers} from '../convert/index.js';
|
|
8
14
|
import {resolvePointer} from '../query/util.js';
|
|
9
15
|
|
|
@@ -15,7 +21,7 @@ export async function create({
|
|
|
15
21
|
verifiablePresentation,
|
|
16
22
|
presentationSubmission,
|
|
17
23
|
authorizationRequest,
|
|
18
|
-
vpToken,
|
|
24
|
+
vpToken, vpTokenMediaType,
|
|
19
25
|
encryptionOptions = {}
|
|
20
26
|
} = {}) {
|
|
21
27
|
if(!(verifiablePresentation || vpToken)) {
|
|
@@ -27,9 +33,10 @@ export async function create({
|
|
|
27
33
|
// if no `vpToken` given, use VP
|
|
28
34
|
vpToken = vpToken ?? JSON.stringify(verifiablePresentation);
|
|
29
35
|
|
|
30
|
-
// if no `presentationSubmission` provided, auto-generate one
|
|
36
|
+
// if no `presentationSubmission` provided, auto-generate one if no
|
|
37
|
+
// `vpTokenMediaType` is given
|
|
31
38
|
let generatedPresentationSubmission = false;
|
|
32
|
-
if(!presentationSubmission) {
|
|
39
|
+
if(!presentationSubmission && vpTokenMediaType === undefined) {
|
|
33
40
|
({presentationSubmission} = createPresentationSubmission({
|
|
34
41
|
presentationDefinition: authorizationRequest.presentation_definition,
|
|
35
42
|
verifiablePresentation
|
|
@@ -37,29 +44,43 @@ export async function create({
|
|
|
37
44
|
generatedPresentationSubmission = true;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
encryptionOptions = _normalizeEncryptionOptions({
|
|
48
|
+
authorizationRequest, encryptionOptions
|
|
49
|
+
});
|
|
50
|
+
|
|
40
51
|
// prepare response body
|
|
41
52
|
const body = {};
|
|
42
53
|
|
|
43
|
-
// if `authorizationRequest.response_mode` is `direct.jwt`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
// if `authorizationRequest.response_mode` is `direct.jwt` or
|
|
55
|
+
// `dc_api.jwt`, then generate a JWT
|
|
56
|
+
if(authorizationRequest.response_mode === 'direct_post.jwt' ||
|
|
57
|
+
authorizationRequest.response_mode === 'dc_api.jwt') {
|
|
58
|
+
// `presentationSubmission` is not used in OID4VP 1.0+
|
|
59
|
+
if((vpTokenMediaType === 'application/mdl-vp-token' ||
|
|
60
|
+
submitsFormat({presentationSubmission, format: 'mso_mdoc'})) &&
|
|
61
|
+
!encryptionOptions?.mdl?.handover) {
|
|
47
62
|
throw createNamedError({
|
|
48
|
-
message: '"encryptionOptions.mdl.
|
|
49
|
-
'
|
|
63
|
+
message: '"encryptionOptions.mdl.handover" is required ' +
|
|
64
|
+
'to submit an mDL presentation.',
|
|
50
65
|
name: 'DataError'
|
|
51
66
|
});
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
const jwt = await _encrypt({
|
|
55
|
-
vpToken, presentationSubmission, authorizationRequest,
|
|
56
|
-
encryptionOptions
|
|
70
|
+
vpToken, presentationSubmission, authorizationRequest, encryptionOptions
|
|
57
71
|
});
|
|
58
72
|
body.response = jwt;
|
|
73
|
+
} else if(encryptionOptions?.mdl?.handover?.type === 'dcapi') {
|
|
74
|
+
// ISO 18013-7 Annex C (HPKE encrypted payload)
|
|
75
|
+
body.Response = await _encrypt({
|
|
76
|
+
vpToken, presentationSubmission, authorizationRequest, encryptionOptions
|
|
77
|
+
});
|
|
59
78
|
} else {
|
|
60
|
-
// include vp token and presentation
|
|
79
|
+
// include vp token and presentation submission directly in body
|
|
61
80
|
body.vp_token = vpToken;
|
|
62
|
-
|
|
81
|
+
if(presentationSubmission) {
|
|
82
|
+
body.presentation_submission = JSON.stringify(presentationSubmission);
|
|
83
|
+
}
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
const authorizationResponse = body;
|
|
@@ -70,11 +91,19 @@ export async function create({
|
|
|
70
91
|
return {authorizationResponse};
|
|
71
92
|
}
|
|
72
93
|
|
|
94
|
+
export function selectRecipientPublicJwk({authorizationRequest} = {}) {
|
|
95
|
+
// get recipient public JWK from client_metadata JWK key set
|
|
96
|
+
const jwks = authorizationRequest?.client_metadata?.jwks;
|
|
97
|
+
return selectJwk({
|
|
98
|
+
keys: jwks?.keys, alg: 'ECDH-ES', kty: 'EC', crv: 'P-256', use: 'enc'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
export async function send({
|
|
74
103
|
verifiablePresentation,
|
|
75
104
|
presentationSubmission,
|
|
76
105
|
authorizationRequest,
|
|
77
|
-
vpToken,
|
|
106
|
+
vpToken, vpTokenMediaType,
|
|
78
107
|
encryptionOptions = {},
|
|
79
108
|
authorizationResponse,
|
|
80
109
|
agent
|
|
@@ -90,7 +119,7 @@ export async function send({
|
|
|
90
119
|
verifiablePresentation,
|
|
91
120
|
presentationSubmission,
|
|
92
121
|
authorizationRequest,
|
|
93
|
-
vpToken,
|
|
122
|
+
vpToken, vpTokenMediaType,
|
|
94
123
|
encryptionOptions
|
|
95
124
|
}));
|
|
96
125
|
} else if(verifiablePresentation || presentationSubmission || vpToken ||
|
|
@@ -204,48 +233,41 @@ export function submitsFormat({presentationSubmission, format} = {}) {
|
|
|
204
233
|
async function _encrypt({
|
|
205
234
|
vpToken, presentationSubmission, authorizationRequest, encryptionOptions
|
|
206
235
|
}) {
|
|
207
|
-
// get recipient public JWK
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
keys: jwks?.keys, alg: 'ECDH-ES', kty: 'EC', crv: 'P-256', use: 'enc'
|
|
211
|
-
});
|
|
236
|
+
// get recipient public JWK
|
|
237
|
+
const recipientPublicJwk = encryptionOptions?.recipientPublicJwk ??
|
|
238
|
+
selectRecipientPublicJwk({authorizationRequest});
|
|
212
239
|
if(!recipientPublicJwk) {
|
|
213
240
|
throw createNamedError({
|
|
214
|
-
message:
|
|
215
|
-
'JWK key set.',
|
|
241
|
+
message:
|
|
242
|
+
'No matching key found for "ECDH-ES" in client meta data JWK key set.',
|
|
216
243
|
name: 'NotFoundError'
|
|
217
244
|
});
|
|
218
245
|
}
|
|
246
|
+
encryptionOptions = {...encryptionOptions, recipientPublicJwk};
|
|
219
247
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
248
|
+
// determine if encrypting to a JWT or using HPKE...
|
|
249
|
+
|
|
250
|
+
// only mDL Annex C uses HPKE at this time; handover type 'dcapi' === Annex C
|
|
251
|
+
if(encryptionOptions?.mdl?.handover?.type === 'dcapi') {
|
|
252
|
+
const pt = typeof vpToken === 'string' ?
|
|
253
|
+
base64url.decode(vpToken) : vpToken;
|
|
254
|
+
// set encoded session transcript as `info`
|
|
255
|
+
const info = await encodeSessionTranscript({
|
|
256
|
+
handover: encryptionOptions.mdl.handover
|
|
257
|
+
});
|
|
224
258
|
const {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// note: `EncryptJWT` API requires `apu/apv` (`partyInfoU`/`partyInfoV`)
|
|
230
|
-
// to be passed as Uint8Arrays; they will be encoded using `base64url` by
|
|
231
|
-
// that API
|
|
232
|
-
keyManagementParameters.apu = TEXT_ENCODER.encode(mdocGeneratedNonce);
|
|
233
|
-
keyManagementParameters.apv = TEXT_ENCODER.encode(verifierGeneratedNonce);
|
|
259
|
+
enc, ct: cipherText
|
|
260
|
+
} = await hpkeEncrypt({pt, info, encryptionOptions});
|
|
261
|
+
const EncryptedResponse = ['dcapi', {enc, cipherText}];
|
|
262
|
+
return base64url.encode(cborEncode(EncryptedResponse));
|
|
234
263
|
}
|
|
235
264
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
alg: 'ECDH-ES',
|
|
243
|
-
enc: encryptionOptions?.enc ?? 'A256GCM',
|
|
244
|
-
kid: recipientPublicJwk.kid
|
|
245
|
-
})
|
|
246
|
-
.setKeyManagementParameters(keyManagementParameters)
|
|
247
|
-
.encrypt(recipientPublicJwk);
|
|
248
|
-
return jwt;
|
|
265
|
+
// all other cases presently use JWT
|
|
266
|
+
const payload = {vp_token: vpToken};
|
|
267
|
+
if(presentationSubmission) {
|
|
268
|
+
payload.presentation_submission = presentationSubmission;
|
|
269
|
+
}
|
|
270
|
+
return jwtEncrypt({payload, encryptionOptions});
|
|
249
271
|
}
|
|
250
272
|
|
|
251
273
|
function _filterToValue({filter, strict = false}) {
|
|
@@ -377,3 +399,53 @@ function _matchesInputDescriptor({
|
|
|
377
399
|
|
|
378
400
|
return true;
|
|
379
401
|
}
|
|
402
|
+
|
|
403
|
+
function _normalizeEncryptionOptions({
|
|
404
|
+
authorizationRequest, encryptionOptions
|
|
405
|
+
}) {
|
|
406
|
+
if(encryptionOptions?.mdl?.sessionTranscript &&
|
|
407
|
+
!encryptionOptions?.mdl.handover) {
|
|
408
|
+
// deprecated Annex B style handover info
|
|
409
|
+
encryptionOptions = {
|
|
410
|
+
...encryptionOptions,
|
|
411
|
+
mdl: {
|
|
412
|
+
...encryptionOptions.mdl,
|
|
413
|
+
handover: {
|
|
414
|
+
type: 'AnnexBHandover',
|
|
415
|
+
...encryptionOptions.mdl.sessionTranscript
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// configure `keyManagementParameters` for `EncryptJWT` API
|
|
422
|
+
if(!encryptionOptions?.keyManagementParameters) {
|
|
423
|
+
const keyManagementParameters = {};
|
|
424
|
+
if(encryptionOptions?.mdl?.handover) {
|
|
425
|
+
// ISO 18013-7 Annex B has specific handover params for apu + apv; for
|
|
426
|
+
// Annex D generate `apu` and use `nonce` for `apv` but this isn't a
|
|
427
|
+
// requirement; Annex C uses HPKE not a JWT so not relevant here
|
|
428
|
+
const {
|
|
429
|
+
mdocGeneratedNonce,
|
|
430
|
+
nonce,
|
|
431
|
+
verifierGeneratedNonce
|
|
432
|
+
} = encryptionOptions.mdl.handover;
|
|
433
|
+
|
|
434
|
+
// generate 128-bit random `apu` if no `mdocGeneratedNonce` provided
|
|
435
|
+
const apu = mdocGeneratedNonce ??
|
|
436
|
+
globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
437
|
+
// default to using `authorizationRequest.nonce` for verifier nonce
|
|
438
|
+
const apv = verifierGeneratedNonce ?? nonce ?? authorizationRequest.nonce;
|
|
439
|
+
// note: `EncryptJWT` API requires `apu/apv` (`partyInfoU`/`partyInfoV`)
|
|
440
|
+
// to be passed as Uint8Arrays; they will be encoded using `base64url` by
|
|
441
|
+
// that API
|
|
442
|
+
keyManagementParameters.apu = typeof apu === 'string' ?
|
|
443
|
+
TEXT_ENCODER.encode(apu) : apu;
|
|
444
|
+
keyManagementParameters.apv = typeof apv === 'string' ?
|
|
445
|
+
TEXT_ENCODER.encode(apv) : apv;
|
|
446
|
+
}
|
|
447
|
+
encryptionOptions = {...encryptionOptions, keyManagementParameters};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return encryptionOptions;
|
|
451
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
Aes128Gcm,
|
|
6
|
+
CipherSuite,
|
|
7
|
+
DhkemP256HkdfSha256,
|
|
8
|
+
HkdfSha256
|
|
9
|
+
} from '@hpke/core';
|
|
10
|
+
import {createNamedError, selectJwk} from '../util.js';
|
|
11
|
+
import {importJWK} from 'jose';
|
|
12
|
+
|
|
13
|
+
// `enc`: encapsulated sender public key
|
|
14
|
+
// `ct`: cipher text
|
|
15
|
+
export async function decrypt({enc, ct, getDecryptParameters}) {
|
|
16
|
+
if(typeof getDecryptParameters !== 'function') {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
'"getDecryptParameters" is required for "direct_post.jwt" ' +
|
|
19
|
+
'response mode.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// load decryption parameters
|
|
23
|
+
const params = await getDecryptParameters({enc});
|
|
24
|
+
const {keys} = params;
|
|
25
|
+
let {getKey} = params;
|
|
26
|
+
let recipientPublicJwk;
|
|
27
|
+
if(!getKey) {
|
|
28
|
+
// process `enc` to find key
|
|
29
|
+
getKey = async ({enc}) => {
|
|
30
|
+
// import sender key and export as JWK to find a matching recipient key
|
|
31
|
+
// for decryption
|
|
32
|
+
const jwk = await _rawPublicKeyToJwk({rawPublicKey: enc});
|
|
33
|
+
const match = selectJwk({keys, kty: jwk.kty, crv: jwk.crv});
|
|
34
|
+
if(!match) {
|
|
35
|
+
throw createNamedError({
|
|
36
|
+
message: 'No matching recipient cryptographic key found.',
|
|
37
|
+
name: 'NotSupportedError',
|
|
38
|
+
details: {httpStatusCode: 400, public: true}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const {d, ...rest} = match;
|
|
42
|
+
const recipientSecretJwk = {...rest, d};
|
|
43
|
+
recipientPublicJwk = {alg: 'ECDH-ES', use: 'enc', ...rest};
|
|
44
|
+
return importJWK(recipientSecretJwk, 'ECDH-ES', {extractable: true});
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const recipientKey = await getKey({enc});
|
|
48
|
+
const info = await params?.getInfo?.({enc, recipientPublicJwk});
|
|
49
|
+
const aad = await params?.getAad?.({enc, info, recipientPublicJwk});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// open: ciphertext + encapsulated key => plaintext
|
|
53
|
+
const suite = _createCiphersuite();
|
|
54
|
+
const recipient = await suite.createRecipientContext({
|
|
55
|
+
recipientKey, enc, info
|
|
56
|
+
});
|
|
57
|
+
const pt = new Uint8Array(await recipient.open(ct, aad));
|
|
58
|
+
return {pt, recipientPublicJwk};
|
|
59
|
+
} catch(cause) {
|
|
60
|
+
throw createNamedError({
|
|
61
|
+
message: `Decryption failed.`,
|
|
62
|
+
name: 'DataError',
|
|
63
|
+
details: {httpStatusCode: 400, public: true},
|
|
64
|
+
cause
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function encrypt({pt, info, aad, encryptionOptions}) {
|
|
70
|
+
if(encryptionOptions?.enc && !(encryptionOptions.enc !== 'A128GCM')) {
|
|
71
|
+
throw createNamedError({
|
|
72
|
+
message:
|
|
73
|
+
`Unsupported encryption algorithm "${encryptionOptions.enc}"; ` +
|
|
74
|
+
'only "A128GCM" is supported.',
|
|
75
|
+
name: 'DataError'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// import recipient public key
|
|
80
|
+
const {recipientPublicJwk} = encryptionOptions;
|
|
81
|
+
const recipientPublicKey = await importJWK(recipientPublicJwk, 'ECDH-ES');
|
|
82
|
+
|
|
83
|
+
// seal: plaintext => ciphertext + encapsulated key
|
|
84
|
+
const suite = _createCiphersuite();
|
|
85
|
+
const sender = await suite.createSenderContext({
|
|
86
|
+
recipientPublicKey, info
|
|
87
|
+
});
|
|
88
|
+
const ct = await sender.seal(pt, aad);
|
|
89
|
+
return {enc: sender.enc, ct};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// only supported cipher suite
|
|
93
|
+
function _createCiphersuite() {
|
|
94
|
+
return new CipherSuite({
|
|
95
|
+
kem: new DhkemP256HkdfSha256(),
|
|
96
|
+
kdf: new HkdfSha256(),
|
|
97
|
+
aead: new Aes128Gcm()
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function _rawPublicKeyToJwk({rawPublicKey}) {
|
|
102
|
+
try {
|
|
103
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
104
|
+
'raw', rawPublicKey, {name: 'ECDH', namedCurve: 'P-256'},
|
|
105
|
+
true, []);
|
|
106
|
+
const jwk = await globalThis.crypto.subtle.exportKey('jwk', publicKey);
|
|
107
|
+
return jwk;
|
|
108
|
+
} catch(e) {
|
|
109
|
+
throw createNamedError({
|
|
110
|
+
message:
|
|
111
|
+
'Unsupported public key; it must be "ECDH-ES" with curve "P-256".',
|
|
112
|
+
name: 'NotSupportedError',
|
|
113
|
+
details: {httpStatusCode: 400, public: true}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/oid4vp/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2023-
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
4
|
export * as authzRequest from './authorizationRequest.js';
|
|
5
5
|
export * as authzResponse from './authorizationResponse.js';
|
|
6
6
|
export * as convert from '../convert/index.js';
|
|
7
|
+
export * as mdl from './mdl.js';
|
|
7
8
|
export * as verifier from './verifier.js';
|
|
8
9
|
|
|
9
10
|
// backwards compatibility APIs
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {createNamedError, selectJwk} from '../util.js';
|
|
5
|
+
import {EncryptJWT, importJWK, jwtDecrypt} from 'jose';
|
|
6
|
+
|
|
7
|
+
export async function decrypt({jwt, getDecryptParameters}) {
|
|
8
|
+
if(typeof getDecryptParameters !== 'function') {
|
|
9
|
+
throw new TypeError(
|
|
10
|
+
'"getDecryptParameters" is required for "direct_post.jwt" ' +
|
|
11
|
+
'response mode.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const params = await getDecryptParameters({jwt});
|
|
15
|
+
const {keys} = params;
|
|
16
|
+
let {getKey} = params;
|
|
17
|
+
let recipientPublicJwk;
|
|
18
|
+
if(!getKey) {
|
|
19
|
+
// note: `jose` lib's JWK key set feature cannot be used and passed to
|
|
20
|
+
// `jwtDecrypt()` as the second parameter because the expected `alg`
|
|
21
|
+
// "ECDH-ES" is not a unsupported algorithm for selecting a key from a set
|
|
22
|
+
getKey = protectedHeader => {
|
|
23
|
+
if(protectedHeader.alg !== 'ECDH-ES') {
|
|
24
|
+
throw createNamedError({
|
|
25
|
+
message: `Unsupported algorithm "${protectedHeader.alg}"; ` +
|
|
26
|
+
'algorithm must be "ECDH-ES".',
|
|
27
|
+
name: 'NotSupportedError',
|
|
28
|
+
details: {httpStatusCode: 400, public: true}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const {d, ...rest} = selectJwk({keys, kid: protectedHeader.kid});
|
|
32
|
+
const recipientSecretJwk = {...rest, d};
|
|
33
|
+
recipientPublicJwk = {alg: 'ECDH-ES', use: 'enc', ...rest};
|
|
34
|
+
return importJWK(recipientSecretJwk, 'ECDH-ES');
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const {payload, protectedHeader} = await jwtDecrypt(jwt, getKey, {
|
|
40
|
+
// only supported algorithms at this time:
|
|
41
|
+
contentEncryptionAlgorithms: ['A256GCM', 'A128GCM'],
|
|
42
|
+
keyManagementAlgorithms: ['ECDH-ES']
|
|
43
|
+
});
|
|
44
|
+
return {payload, protectedHeader, recipientPublicJwk};
|
|
45
|
+
} catch(cause) {
|
|
46
|
+
throw createNamedError({
|
|
47
|
+
message: `Decryption failed.`,
|
|
48
|
+
name: 'DataError',
|
|
49
|
+
details: {httpStatusCode: 400, public: true},
|
|
50
|
+
cause
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function encrypt({payload, encryptionOptions}) {
|
|
56
|
+
const {keyManagementParameters, recipientPublicJwk} = encryptionOptions;
|
|
57
|
+
const jwt = await new EncryptJWT(payload)
|
|
58
|
+
.setProtectedHeader({
|
|
59
|
+
alg: 'ECDH-ES',
|
|
60
|
+
enc: encryptionOptions?.enc ?? 'A256GCM',
|
|
61
|
+
kid: recipientPublicJwk.kid
|
|
62
|
+
})
|
|
63
|
+
.setKeyManagementParameters(keyManagementParameters)
|
|
64
|
+
.encrypt(recipientPublicJwk);
|
|
65
|
+
return jwt;
|
|
66
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import * as base64url from 'base64url-universal';
|
|
5
|
+
import {decode as cborDecode, encode as cborEncode, Token, Type} from 'cborg';
|
|
6
|
+
import {createNamedError, jwkToCoseKey, sha256} from '../util.js';
|
|
7
|
+
import {calculateJwkThumbprint} from 'jose';
|
|
8
|
+
import {decrypt as hpkeDecrypt} from './hpke.js';
|
|
9
|
+
|
|
10
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encodes a `SessionTranscript` for use with mDL (ISO 18013-7 variants).
|
|
14
|
+
*
|
|
15
|
+
* The `handover` parameter's properties, other than `type`, depend on the
|
|
16
|
+
* value of `type`:
|
|
17
|
+
*
|
|
18
|
+
* For 'AnnexBHandover':
|
|
19
|
+
* mdocGeneratedNonce, clientId, responseUri, verifierGeneratedNonce
|
|
20
|
+
* For 'OpenID4VPDCAPIHandover':
|
|
21
|
+
* origin, nonce, jwkThumbprint
|
|
22
|
+
* For 'dcapi':
|
|
23
|
+
* nonce, recipientPublicKey.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options - The options.
|
|
26
|
+
* @param {object} options.handover - The handover options to use in the
|
|
27
|
+
* session transcript, including the `type` and any type-specific properties;
|
|
28
|
+
* the `type` can be any of the following:
|
|
29
|
+
* 'AnnexBHandover' for (ISO 18013-7 Annex B),
|
|
30
|
+
* 'OpenID4VPDCAPIHandover' for ISO 18013-7 Annex D "Google DC API",
|
|
31
|
+
* 'dcapi' for ISO 18013-7 Annex C "Apple DC API".
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<Uint8Array>} The cbor-encoded session transcript.
|
|
34
|
+
*/
|
|
35
|
+
export async function encodeSessionTranscript({handover} = {}) {
|
|
36
|
+
// produce `Handover` component of mDL session transcript
|
|
37
|
+
let Handover;
|
|
38
|
+
if(handover.type === 'AnnexBHandover') {
|
|
39
|
+
Handover = await _encodeAnnexBHandover({handover});
|
|
40
|
+
} else if(handover.type === 'dcapi') {
|
|
41
|
+
Handover = await _encodeAnnexCHandover({handover});
|
|
42
|
+
} else if(handover.type === 'OpenID4VPDCAPIHandover') {
|
|
43
|
+
Handover = await _encodeAnnexDHandover({handover});
|
|
44
|
+
} else {
|
|
45
|
+
throw new Error(`Unknown handover type "${handover.type}".`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// create session transcript which is always:
|
|
49
|
+
// `[DeviceEngagementBytes, EReaderKeyBytes, Handover]`
|
|
50
|
+
// where `DeviceEngagementBytes` and `EReaderKeyBytes` are `null`
|
|
51
|
+
const sessionTranscript = [null, null, Handover];
|
|
52
|
+
|
|
53
|
+
// session transcript bytes are encoded as a CBOR data item within a byte
|
|
54
|
+
// string (CBOR Tag 24):
|
|
55
|
+
const dataItem = cborEncode(sessionTranscript);
|
|
56
|
+
return cborEncode(dataItem, {
|
|
57
|
+
typeEncoders: {Uint8Array: createTag24Encoder(dataItem)}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function decryptAnnexCResponse({
|
|
62
|
+
base64urlEncryptedResponse, getDecryptParameters
|
|
63
|
+
} = {}) {
|
|
64
|
+
// ISO 18013-7 Annex C, with hpke-encrypted payload
|
|
65
|
+
const EncryptedResponse = cborDecode(
|
|
66
|
+
base64url.decode(base64urlEncryptedResponse));
|
|
67
|
+
const [protocol] = EncryptedResponse;
|
|
68
|
+
if(protocol !== 'dcapi') {
|
|
69
|
+
throw createNamedError({
|
|
70
|
+
message: `Unsupported encryption protocol "${protocol}".`,
|
|
71
|
+
name: 'NotSupportedError'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const [, {enc, cipherText: ct}] = EncryptedResponse;
|
|
75
|
+
return hpkeDecrypt({enc, ct, getDecryptParameters});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createTag24Encoder(value) {
|
|
79
|
+
return function tag24Encoder(obj) {
|
|
80
|
+
if(obj !== value) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return [
|
|
84
|
+
new Token(Type.tag, 24),
|
|
85
|
+
new Token(Type.bytes, obj)
|
|
86
|
+
];
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// encode `handover` as ISO 18013-7 Annex B Handover
|
|
91
|
+
async function _encodeAnnexBHandover({handover}) {
|
|
92
|
+
const {
|
|
93
|
+
mdocGeneratedNonce,
|
|
94
|
+
clientId,
|
|
95
|
+
responseUri,
|
|
96
|
+
verifierGeneratedNonce
|
|
97
|
+
} = handover;
|
|
98
|
+
return [mdocGeneratedNonce, clientId, responseUri, verifierGeneratedNonce];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// encode `handover` as ISO 18013-7 Annex C Handover
|
|
102
|
+
async function _encodeAnnexCHandover({handover}) {
|
|
103
|
+
/* Details:
|
|
104
|
+
|
|
105
|
+
Handover = `['dcapi', dcapiInfoHash]`
|
|
106
|
+
dcapiInfo = [Base64EncryptionInfo, SerializedOrigin]
|
|
107
|
+
|
|
108
|
+
SerializedOrigin = tstr
|
|
109
|
+
dcapiInfoHash = bstr
|
|
110
|
+
|
|
111
|
+
`Base64EncryptionInfo` is the base64url-no-pad encoding of the cbor-encoded
|
|
112
|
+
`EncryptionInfo`.
|
|
113
|
+
|
|
114
|
+
EncryptionInfo = [
|
|
115
|
+
// encryption protocol identifier
|
|
116
|
+
'dcapi',
|
|
117
|
+
EncryptionParameters
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
EncryptionParameters = {
|
|
121
|
+
// binary string
|
|
122
|
+
nonce,
|
|
123
|
+
// COSE key
|
|
124
|
+
recipientPublicKey
|
|
125
|
+
}
|
|
126
|
+
*/
|
|
127
|
+
const {origin, nonce} = handover;
|
|
128
|
+
// if `recipientPublicKey` is not present, convert it from
|
|
129
|
+
// `recipientPublicKeyJwk`
|
|
130
|
+
const recipientPublicKey = handover.recipientPublicKey ??
|
|
131
|
+
jwkToCoseKey({jwk: handover.recipientPublicJwk});
|
|
132
|
+
const nonceBytes = typeof nonce === 'string' ?
|
|
133
|
+
TEXT_ENCODER.encode(nonce) : nonce;
|
|
134
|
+
const EncryptionParameters = [nonceBytes, recipientPublicKey];
|
|
135
|
+
const EncryptionInfo = ['dcapi', EncryptionParameters];
|
|
136
|
+
const Base64EncryptionInfo = base64url.encode(cborEncode(EncryptionInfo));
|
|
137
|
+
const dcapiInfo = [Base64EncryptionInfo, origin];
|
|
138
|
+
const dcapiInfoHash = await sha256(cborEncode(dcapiInfo));
|
|
139
|
+
return ['dcapi', dcapiInfoHash];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// encode `handover` as ISO 18013-7 Annex D Handover
|
|
143
|
+
async function _encodeAnnexDHandover({handover}) {
|
|
144
|
+
// https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#appendix-B.2.6.2
|
|
145
|
+
// for `"response_mode": "dc_api"`, `jwkThumbprint` MUST be `null`
|
|
146
|
+
// for `"response_mode": "dc_api.jwt"`, `jwkThumbprint` MUST be the JWK
|
|
147
|
+
// SHA-256 Thumbprint of the verifier's public key used to encrypt
|
|
148
|
+
// the response (as a Uint8Array)
|
|
149
|
+
const {origin, nonce} = handover;
|
|
150
|
+
|
|
151
|
+
// calculate thumbprint if only `recipientPublicJwk` given
|
|
152
|
+
if(!handover.jwkThumbprint && handover.recipientPublicJwk) {
|
|
153
|
+
handover = {
|
|
154
|
+
...handover,
|
|
155
|
+
jwkThumbprint: await calculateJwkThumbprint(handover.recipientPublicJwk)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const jwkThumbprint = typeof handover.jwkThumbprint === 'string' ?
|
|
159
|
+
base64url.decode(handover.jwkThumbprint) : handover.jwkThumbprint;
|
|
160
|
+
const handoverInfo = [origin, nonce, jwkThumbprint];
|
|
161
|
+
const handoverInfoHash = await sha256(cborEncode(handoverInfo));
|
|
162
|
+
return ['OpenID4VPDCAPIHandover', handoverInfoHash];
|
|
163
|
+
}
|
package/lib/oid4vp/verifier.js
CHANGED
|
@@ -1,57 +1,115 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2023-
|
|
2
|
+
* Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import * as base64url from 'base64url-universal';
|
|
5
|
+
import {createNamedError, parseJSON} from '../util.js';
|
|
6
|
+
import {calculateJwkThumbprint} from 'jose';
|
|
7
|
+
import {decryptAnnexCResponse} from './mdl.js';
|
|
8
|
+
import {decrypt as jwtDecrypt} from './jwt.js';
|
|
9
|
+
|
|
10
|
+
// start of JSON object, array, or string
|
|
11
|
+
const VP_TOKEN_JSON_PREFIXES = new Set(['{', '[', '"']);
|
|
6
12
|
|
|
7
13
|
// parses (and decrypts) an authz response from a response body object
|
|
8
14
|
export async function parseAuthorizationResponse({
|
|
9
15
|
body = {},
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
getDecryptParameters,
|
|
17
|
+
authorizationRequest,
|
|
18
|
+
// only used if `authorizationRequest.response_mode` is not set, otherwise
|
|
19
|
+
// the response must match the authz request's response mode
|
|
20
|
+
supportedResponseModes = [
|
|
21
|
+
'direct_post.jwt', 'direct_post', 'dc_api.jwt', 'dc_api'
|
|
22
|
+
]
|
|
12
23
|
}) {
|
|
13
24
|
let responseMode;
|
|
14
25
|
const parsed = {};
|
|
26
|
+
let vpTokenMediaType = 'application/octet-stream';
|
|
15
27
|
let payload;
|
|
16
28
|
let protectedHeader;
|
|
29
|
+
let recipientPublicJwk;
|
|
17
30
|
|
|
18
|
-
supportedResponseModes = new Set(
|
|
31
|
+
supportedResponseModes = new Set(authorizationRequest?.response_mode ?
|
|
32
|
+
[authorizationRequest.response_mode] : supportedResponseModes);
|
|
19
33
|
|
|
20
34
|
if(body.response) {
|
|
21
|
-
// `body.response` is present which must contain an encrypted JWT
|
|
22
|
-
|
|
35
|
+
// `body.response` is present which must contain an encrypted JWT;
|
|
36
|
+
// response mode can also be `dc_api.jwt` here, but distinction can only
|
|
37
|
+
// be made if `authorizationRequest` was passed
|
|
38
|
+
responseMode = authorizationRequest?.response_mode === 'dc_api.jwt' ?
|
|
39
|
+
'dc_api.jwt' : 'direct_post.jwt';
|
|
23
40
|
_assertSupportedResponseMode({responseMode, supportedResponseModes});
|
|
24
41
|
const jwt = body.response;
|
|
25
42
|
({
|
|
26
43
|
payload,
|
|
27
|
-
protectedHeader
|
|
28
|
-
|
|
44
|
+
protectedHeader,
|
|
45
|
+
recipientPublicJwk
|
|
46
|
+
} = await jwtDecrypt({jwt, getDecryptParameters}));
|
|
29
47
|
parsed.presentationSubmission = payload.presentation_submission;
|
|
48
|
+
} else if(body.Response) {
|
|
49
|
+
// ISO 18013-7 Annex C, with hpke-encrypted payload
|
|
50
|
+
responseMode = 'dc_api';
|
|
51
|
+
_assertSupportedResponseMode({responseMode, supportedResponseModes});
|
|
52
|
+
const base64urlEncryptedResponse = body.Response;
|
|
53
|
+
({pt: payload, recipientPublicJwk} = await decryptAnnexCResponse({
|
|
54
|
+
base64urlEncryptedResponse, getDecryptParameters
|
|
55
|
+
}));
|
|
56
|
+
// normalize payload to base64url-encoded mDL device response
|
|
57
|
+
parsed.vpToken = base64url.encode(payload);
|
|
58
|
+
vpTokenMediaType = 'application/mdl-vp-token';
|
|
30
59
|
} else {
|
|
31
60
|
responseMode = 'direct_post';
|
|
32
61
|
_assertSupportedResponseMode({responseMode, supportedResponseModes});
|
|
33
62
|
payload = body;
|
|
34
|
-
|
|
35
|
-
|
|
63
|
+
if(payload.presentation_submission) {
|
|
64
|
+
parsed.presentationSubmission = parseJSON(
|
|
65
|
+
payload.presentation_submission, 'presentation_submission');
|
|
66
|
+
}
|
|
36
67
|
}
|
|
37
68
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
vp_token
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
// if payload is set but not a Uint8Array (ISO 18013-7 Annex C case)...
|
|
70
|
+
if(payload && !(payload instanceof Uint8Array)) {
|
|
71
|
+
// `vp_token` is either:
|
|
72
|
+
// 1. a JSON object (a VP)
|
|
73
|
+
// 2. a JSON array (of something; unknown media type)
|
|
74
|
+
// 3. a JSON string (a quoted JWT: "<JWT>")
|
|
75
|
+
// 4. a JWT (starts with 'ey'...)
|
|
76
|
+
// 5. a base64url-encoded mDL device response
|
|
77
|
+
// 6. unknown
|
|
78
|
+
const {vp_token} = payload;
|
|
79
|
+
if(typeof vp_token === 'string') {
|
|
80
|
+
if(VP_TOKEN_JSON_PREFIXES.has(vp_token[0])) {
|
|
81
|
+
// cases: 1-3 - JSON
|
|
82
|
+
parsed.vpToken = parseJSON(vp_token, 'vp_token');
|
|
83
|
+
if(typeof parsed.vpToken === 'string') {
|
|
84
|
+
vpTokenMediaType = 'application/jwt';
|
|
85
|
+
} else if(!Array.isArray(parsed.vpToken)) {
|
|
86
|
+
vpTokenMediaType = 'application/vp';
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// cases 4-5: JWT or mDL device response
|
|
90
|
+
parsed.vpToken = vp_token;
|
|
91
|
+
// if does not look like a JWT, assume mDL device response
|
|
92
|
+
vpTokenMediaType = vp_token.startsWith('ey') ?
|
|
93
|
+
'application/jwt' : 'application/mdl-vp-token';
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// unknown case
|
|
97
|
+
parsed.vpToken = vp_token;
|
|
98
|
+
}
|
|
52
99
|
}
|
|
53
100
|
|
|
54
|
-
|
|
101
|
+
// calculate JWK thumbprint for recipient public key, if any
|
|
102
|
+
let recipientPublicJwkThumbprint;
|
|
103
|
+
if(recipientPublicJwk) {
|
|
104
|
+
recipientPublicJwkThumbprint = await calculateJwkThumbprint(
|
|
105
|
+
recipientPublicJwk);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
responseMode, parsed, payload, protectedHeader,
|
|
110
|
+
recipientPublicJwk, recipientPublicJwkThumbprint,
|
|
111
|
+
vpTokenMediaType
|
|
112
|
+
};
|
|
55
113
|
}
|
|
56
114
|
|
|
57
115
|
function _assertSupportedResponseMode({
|
|
@@ -60,43 +118,8 @@ function _assertSupportedResponseMode({
|
|
|
60
118
|
if(!supportedResponseModes.has(responseMode)) {
|
|
61
119
|
throw createNamedError({
|
|
62
120
|
message: `Unsupported response mode "${responseMode}".`,
|
|
63
|
-
name: 'NotSupportedError'
|
|
121
|
+
name: 'NotSupportedError',
|
|
122
|
+
details: {httpStatusCode: 400, public: true}
|
|
64
123
|
});
|
|
65
124
|
}
|
|
66
125
|
}
|
|
67
|
-
|
|
68
|
-
async function _decrypt({jwt, getDecryptParameters}) {
|
|
69
|
-
if(typeof getDecryptParameters !== 'function') {
|
|
70
|
-
throw new TypeError(
|
|
71
|
-
'"getDecryptParameters" is required for "direct_post.jwt" ' +
|
|
72
|
-
'response mode.');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const params = await getDecryptParameters({jwt});
|
|
76
|
-
const {keys} = params;
|
|
77
|
-
let {getKey} = params;
|
|
78
|
-
if(!getKey) {
|
|
79
|
-
// note: `jose` lib's JWK key set feature cannot be used and passed to
|
|
80
|
-
// `jwtDecrypt()` as the second parameter because the expected `alg`
|
|
81
|
-
// "ECDH-ES" is not a unsupported algorithm for selecting a key from a set
|
|
82
|
-
getKey = protectedHeader => {
|
|
83
|
-
if(protectedHeader.alg !== 'ECDH-ES') {
|
|
84
|
-
const error = createNamedError({
|
|
85
|
-
message: `Unsupported algorithm "${protectedHeader.alg}"; ` +
|
|
86
|
-
'algorithm must be "ECDH-ES".',
|
|
87
|
-
name: 'NotSupportedError',
|
|
88
|
-
details: {httpStatusCode: 400, public: true}
|
|
89
|
-
});
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
const jwk = selectJwk({keys, kid: protectedHeader.kid});
|
|
93
|
-
return importJWK(jwk, 'ECDH-ES');
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return jwtDecrypt(jwt, getKey, {
|
|
98
|
-
// only supported algorithms at this time:
|
|
99
|
-
contentEncryptionAlgorithms: ['A256GCM', 'A128GCM'],
|
|
100
|
-
keyManagementAlgorithms: ['ECDH-ES']
|
|
101
|
-
});
|
|
102
|
-
}
|
|
@@ -6,6 +6,7 @@ import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
|
6
6
|
import {JSONPath} from 'jsonpath-plus';
|
|
7
7
|
import jsonpointer from 'json-pointer';
|
|
8
8
|
|
|
9
|
+
const MDOC_MDL = 'org.iso.18013.5.1.mDL';
|
|
9
10
|
const VALUE_TYPES = new Set(['string', 'number', 'boolean']);
|
|
10
11
|
const SUPPORTED_JWT_ALGS = ['EdDSA', 'Ed25519', 'ES256', 'ES384'];
|
|
11
12
|
|
|
@@ -98,6 +99,15 @@ export function vprGroupsToPresentationDefinition({
|
|
|
98
99
|
};
|
|
99
100
|
acceptsVcJwt = true;
|
|
100
101
|
}
|
|
102
|
+
if(envelopes?.includes('application/mdl') ||
|
|
103
|
+
envelopes?.includes('application/mdoc')) {
|
|
104
|
+
inputDescriptor.format.mso_mdoc = {};
|
|
105
|
+
// `inputDescriptor` MUST be `org.iso.18013.5.1.mDL` for an mDL
|
|
106
|
+
// query for ecosystem compatibility
|
|
107
|
+
if(envelopes?.includes('application/mdl')) {
|
|
108
|
+
inputDescriptor.id = MDOC_MDL;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
101
111
|
if(cryptosuites?.length > 0) {
|
|
102
112
|
inputDescriptor.format.ldp_vc = {
|
|
103
113
|
proof_type: cryptosuites
|
package/lib/util.js
CHANGED
|
@@ -47,7 +47,7 @@ export function createNamedError({message, name, details, cause} = {}) {
|
|
|
47
47
|
return error;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function fetchJSON({url, agent} = {}) {
|
|
50
|
+
export function fetchJSON({method = 'get', url, body, json, agent} = {}) {
|
|
51
51
|
// allow these params to be passed / configured
|
|
52
52
|
const fetchOptions = {
|
|
53
53
|
// max size for issuer config related responses (in bytes, ~4 KiB)
|
|
@@ -57,9 +57,59 @@ export function fetchJSON({url, agent} = {}) {
|
|
|
57
57
|
agent
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
if(method === 'post') {
|
|
61
|
+
if(body !== undefined) {
|
|
62
|
+
fetchOptions.body = body;
|
|
63
|
+
} else if(json !== undefined) {
|
|
64
|
+
fetchOptions.json = json;
|
|
65
|
+
}
|
|
66
|
+
return httpClient.post(url, fetchOptions);
|
|
67
|
+
}
|
|
60
68
|
return httpClient.get(url, fetchOptions);
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
// only supports keys used w/mDL at this time
|
|
72
|
+
export function jwkToCoseKey({jwk} = {}) {
|
|
73
|
+
if(!(jwk.kty === 'EC' && (jwk.crv === 'P-256' || jwk.crv === 'P-384'))) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'Unknown supported JWK for mDL encryption; ' +
|
|
76
|
+
'only EC P-256 or P-384 are accepted.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// https://datatracker.ietf.org/doc/html/rfc9053
|
|
80
|
+
return {
|
|
81
|
+
// `kty`; only types supported for mDL:
|
|
82
|
+
// `EC2` 2
|
|
83
|
+
1: 2,
|
|
84
|
+
// `crv`; only curves supported for mDL:
|
|
85
|
+
// `P-256` 1
|
|
86
|
+
// `P-384` 2
|
|
87
|
+
'-1': jwk.crv === 'P-256' ? 1 : 2,
|
|
88
|
+
// `x`
|
|
89
|
+
'-2': base64url.decode(jwk.x),
|
|
90
|
+
// `y`
|
|
91
|
+
'-3': base64url.decode(jwk.y)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function coseKeyToJwk({coseKey} = {}) {
|
|
96
|
+
const {1: kty, '-1': crv, '-2': x, '-3': y} = coseKey;
|
|
97
|
+
|
|
98
|
+
if(!(kty === 'EC' && (crv === 'P-256' || crv === 'P-384'))) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Unknown supported COSE key for mDL decryption; ' +
|
|
101
|
+
'only EC P-256 or P-384 are accepted.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// https://datatracker.ietf.org/doc/html/rfc9053
|
|
105
|
+
return {
|
|
106
|
+
kty,
|
|
107
|
+
crv,
|
|
108
|
+
x: base64url.encode(x),
|
|
109
|
+
y: base64url.encode(y)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
63
113
|
export function parseJSON(x, name) {
|
|
64
114
|
try {
|
|
65
115
|
return JSON.parse(x);
|
|
@@ -73,7 +123,9 @@ export function parseJSON(x, name) {
|
|
|
73
123
|
}
|
|
74
124
|
}
|
|
75
125
|
|
|
76
|
-
export function selectJwk({
|
|
126
|
+
export function selectJwk({
|
|
127
|
+
keys, kid, alg, kty, crv, use, x, y
|
|
128
|
+
} = {}) {
|
|
77
129
|
/* Example JWKs "keys":
|
|
78
130
|
"jwks": {
|
|
79
131
|
"keys": [
|
|
@@ -103,16 +155,21 @@ export function selectJwk({keys, kid, alg, kty, crv, use} = {}) {
|
|
|
103
155
|
const kty1 = kty ?? jwk.kty;
|
|
104
156
|
const crv1 = crv ?? jwk.crv;
|
|
105
157
|
const use1 = use ?? jwk.use;
|
|
158
|
+
const x1 = x ?? jwk.x;
|
|
159
|
+
const y1 = y ?? jwk.y;
|
|
106
160
|
const {
|
|
107
161
|
// default missing `alg` value in `jwk` to search value
|
|
108
162
|
alg: alg2 = alg1,
|
|
109
163
|
kty: kty2,
|
|
110
164
|
crv: crv2,
|
|
111
165
|
// default missing `use` value in `jwk` to search value
|
|
112
|
-
use: use2 = use1
|
|
166
|
+
use: use2 = use1,
|
|
167
|
+
x: x2,
|
|
168
|
+
y: y2
|
|
113
169
|
} = jwk;
|
|
114
170
|
// return if `jwk` matches computed values
|
|
115
|
-
return alg1 === alg2 && kty1 === kty2 && crv1 === crv2 && use1 === use2
|
|
171
|
+
return alg1 === alg2 && kty1 === kty2 && crv1 === crv2 && use1 === use2 &&
|
|
172
|
+
x1 == x2 && y1 === y2;
|
|
116
173
|
});
|
|
117
174
|
}
|
|
118
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digitalbazaar/oid4-client",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"description": "An OID4 (VC + VP) client",
|
|
5
5
|
"homepage": "https://github.com/digitalbazaar/oid4-client",
|
|
6
6
|
"author": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@digitalbazaar/http-client": "^4.0.0",
|
|
27
|
+
"@hpke/core": "^1.7.5",
|
|
27
28
|
"base64url-universal": "^2.0.0",
|
|
29
|
+
"cborg": "^4.5.8",
|
|
28
30
|
"jose": "^6.1.0",
|
|
29
31
|
"json-pointer": "^0.6.2",
|
|
30
32
|
"jsonpath-plus": "^10.3.0",
|