@bedrock/vc-delivery 5.0.1 → 5.1.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.
@@ -0,0 +1,530 @@
1
+ /*!
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import * as exchanges from '../exchanges.js';
6
+ import {
7
+ evaluateTemplate, getWorkflowIssuerInstances
8
+ } from '../helpers.js';
9
+ import {importJWK, SignJWT} from 'jose';
10
+ import {checkAccessToken} from '@bedrock/oauth2-verifier';
11
+ import {getAuthorizationRequest} from './oid4vp.js';
12
+ import {issue} from '../issue.js';
13
+ import {timingSafeEqual} from 'node:crypto';
14
+ import {verifyDidProofJwt} from '../verify.js';
15
+
16
+ const {util: {BedrockError}} = bedrock;
17
+
18
+ const PRE_AUTH_GRANT_TYPE =
19
+ 'urn:ietf:params:oauth:grant-type:pre-authorized_code';
20
+
21
+ export async function getAuthorizationServerConfig({req}) {
22
+ // note that technically, we should not need to serve any credential
23
+ // issuer metadata, but we do for backwards compatibility purposes as
24
+ // previous versions of OID4VCI required it
25
+ return getCredentialIssuerConfig({req});
26
+ }
27
+
28
+ export async function getCredentialIssuerConfig({req}) {
29
+ const {config: workflow} = req.serviceObject;
30
+ const {exchange} = await req.getExchange();
31
+ _assertOID4VCISupported({exchange});
32
+
33
+ // build `credential_configurations_supported`...
34
+ const {openId: {expectedCredentialRequests}} = exchange;
35
+ const supportedFormats = [..._getSupportedFormats({workflow})];
36
+
37
+ // for every expected credential definition, set `format` default to
38
+ // `supportedFormats` and for every format, generate a new supported
39
+ // credential configuration
40
+ const credential_configurations_supported = {};
41
+ for(const credentialRequest of expectedCredentialRequests) {
42
+ const configurations = _createCredentialConfigurations({
43
+ credentialRequest, supportedFormats
44
+ });
45
+ for(const {id, configuration} of configurations) {
46
+ credential_configurations_supported[id] = configuration;
47
+ }
48
+ }
49
+
50
+ const exchangeId = `${workflow.id}/exchanges/${exchange.id}`;
51
+ return {
52
+ credential_issuer: exchangeId,
53
+ issuer: exchangeId,
54
+ jwks_uri: `${exchangeId}/openid/jwks`,
55
+ token_endpoint: `${exchangeId}/openid/token`,
56
+ credential_endpoint: `${exchangeId}/openid/credential`,
57
+ batch_credential_endpoint: `${exchangeId}/openid/batch_credential`,
58
+ 'pre-authorized_grant_anonymous_access_supported': true,
59
+ credential_configurations_supported
60
+ };
61
+ }
62
+
63
+ export async function getJwks({req}) {
64
+ const {exchange} = await req.getExchange();
65
+ _assertOID4VCISupported({exchange});
66
+ return [exchange.openId.oauth2.keyPair.publicKeyJwk];
67
+ }
68
+
69
+ export async function processAccessTokenRequest({req}) {
70
+ const exchangeRecord = await req.getExchange();
71
+ const {exchange} = exchangeRecord;
72
+ _assertOID4VCISupported({exchange});
73
+
74
+ /* Examples of types of token requests:
75
+ pre-authz code:
76
+ grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
77
+ &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
78
+ &user_pin=493536
79
+
80
+ authz code:
81
+ grant_type=authorization_code
82
+ &code=SplxlOBeZQQYbYS6WxSbIA
83
+ &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
84
+ &redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb */
85
+
86
+ const {config: workflow} = req.serviceObject;
87
+
88
+ const {
89
+ grant_type: grantType,
90
+ 'pre-authorized_code': preAuthorizedCode,
91
+ // FIXME: `user_pin` now called `tx_code`
92
+ //user_pin: userPin
93
+ } = req.body;
94
+
95
+ if(grantType !== PRE_AUTH_GRANT_TYPE) {
96
+ // unsupported grant type
97
+ // FIXME: throw proper oauth2 formatted error
98
+ throw new Error('Unsupported grant type.');
99
+ }
100
+
101
+ // validate grant type
102
+ const {openId: {preAuthorizedCode: expectedCode}} = exchange;
103
+ if(expectedCode) {
104
+ // ensure expected pre-authz code matches
105
+ if(!timingSafeEqual(
106
+ Buffer.from(expectedCode, 'utf8'),
107
+ Buffer.from(preAuthorizedCode, 'utf8'))) {
108
+ // FIXME: throw proper oauth2 formatted error
109
+ throw new Error('invalid pre-authorized-code or user pin');
110
+ }
111
+ }
112
+
113
+ // create access token
114
+ const {accessToken, ttl} = await _createExchangeAccessToken({
115
+ workflow, exchangeRecord
116
+ });
117
+ return {
118
+ access_token: accessToken,
119
+ token_type: 'bearer',
120
+ expires_in: ttl
121
+ };
122
+ }
123
+
124
+ export async function processCredentialRequests({req, res, isBatchRequest}) {
125
+ const {config: workflow} = req.serviceObject;
126
+ const exchangeRecord = await req.getExchange();
127
+ const {exchange} = exchangeRecord;
128
+ _assertOID4VCISupported({exchange});
129
+
130
+ // ensure oauth2 access token is valid
131
+ await _checkAuthz({req, workflow, exchange});
132
+
133
+ // validate body against expected credential requests
134
+ const {openId: {expectedCredentialRequests}} = exchange;
135
+ let credentialRequests;
136
+ if(isBatchRequest) {
137
+ ({credential_requests: credentialRequests} = req.body);
138
+ } else {
139
+ if(expectedCredentialRequests.length > 1) {
140
+ // FIXME: it is no longer the case that the batch endpoint must be used
141
+ // for multiple requests; determine if the request has changed
142
+
143
+ // clients interacting with exchanges with more than one VC to be
144
+ // delivered must use the "batch credential" endpoint
145
+ // FIXME: improve error
146
+ throw new Error('batch_credential_endpoint must be used');
147
+ }
148
+ credentialRequests = [req.body];
149
+ }
150
+
151
+ // before asserting, normalize credential requests to use `type` instead of
152
+ // `types`; this is to allow for OID4VCI draft implementers that followed
153
+ // the non-normative examples
154
+ _normalizeCredentialDefinitionTypes({credentialRequests});
155
+ const {format} = _assertCredentialRequests({
156
+ workflow, credentialRequests, expectedCredentialRequests
157
+ });
158
+
159
+ // process exchange step if present
160
+ const currentStep = exchange.step;
161
+ if(currentStep) {
162
+ let step = workflow.steps[exchange.step];
163
+ if(step.stepTemplate) {
164
+ // generate step from the template; assume the template type is
165
+ // `jsonata` per the JSON schema
166
+ step = await evaluateTemplate(
167
+ {workflow, exchange, typedTemplate: step.stepTemplate});
168
+ if(Object.keys(step).length === 0) {
169
+ throw new BedrockError('Could not create exchange step.', {
170
+ name: 'DataError',
171
+ details: {httpStatusCode: 500, public: true}
172
+ });
173
+ }
174
+ }
175
+
176
+ // do late workflow configuration validation
177
+ const {jwtDidProofRequest, openId} = step;
178
+ // use of `jwtDidProofRequest` and `openId` together is prohibited
179
+ if(jwtDidProofRequest && openId) {
180
+ throw new BedrockError(
181
+ 'Invalid workflow configuration; only one of ' +
182
+ '"jwtDidProofRequest" and "openId" is permitted in a step.', {
183
+ name: 'DataError',
184
+ details: {httpStatusCode: 500, public: true}
185
+ });
186
+ }
187
+
188
+ // check to see if step supports OID4VP during OID4VCI
189
+ if(step.openId) {
190
+ // if there is no `presentationSubmission`, request one
191
+ const {results} = exchange.variables;
192
+ if(!results?.[exchange.step]?.openId?.presentationSubmission) {
193
+ // FIXME: optimize away double step-template processing that currently
194
+ // occurs when calling `_getAuthorizationRequest`
195
+ const {
196
+ authorizationRequest
197
+ } = await getAuthorizationRequest({req});
198
+ return _requestOID4VP({authorizationRequest, res});
199
+ }
200
+ // otherwise drop down below to complete exchange...
201
+ } else if(jwtDidProofRequest) {
202
+ // handle OID4VCI specialized JWT DID Proof request...
203
+
204
+ // `proof` must be in every credential request; if any request is missing
205
+ // `proof` then request a DID proof
206
+ if(credentialRequests.some(cr => !cr.proof?.jwt)) {
207
+ return _requestDidProof({res, exchangeRecord});
208
+ }
209
+
210
+ // verify every DID proof and get resulting DIDs
211
+ const results = await Promise.all(
212
+ credentialRequests.map(async cr => {
213
+ const {proof: {jwt}} = cr;
214
+ const {did} = await verifyDidProofJwt({workflow, exchange, jwt});
215
+ return did;
216
+ }));
217
+ // require `did` to be the same for every proof
218
+ // FIXME: determine if this needs to be more flexible
219
+ const did = results[0];
220
+ if(results.some(d => did !== d)) {
221
+ // FIXME: improve error
222
+ throw new Error('every DID must be the same');
223
+ }
224
+ // store did results in variables associated with current step
225
+ if(!exchange.variables.results) {
226
+ exchange.variables.results = {};
227
+ }
228
+ exchange.variables.results[currentStep] = {
229
+ // common use case of DID Authentication; provide `did` for ease
230
+ // of use in templates
231
+ did
232
+ };
233
+ }
234
+ }
235
+
236
+ // mark exchange complete
237
+ exchange.sequence++;
238
+ await exchanges.complete({workflowId: workflow.id, exchange});
239
+
240
+ // FIXME: decide what the best recovery path is if delivery fails (but no
241
+ // replay attack detected) after exchange has been marked complete
242
+
243
+ // issue VCs
244
+ return issue({workflow, exchange, format});
245
+ }
246
+
247
+ function _assertCredentialRequests({
248
+ workflow, credentialRequests, expectedCredentialRequests
249
+ }) {
250
+ // ensure that every credential request is for the same format
251
+ /* credential requests look like:
252
+ {
253
+ format: 'ldp_vc',
254
+ credential_definition: { '@context': [Array], type: [Array] }
255
+ }
256
+ */
257
+ let sharedFormat;
258
+ if(!credentialRequests.every(({format}) => {
259
+ if(sharedFormat === undefined) {
260
+ sharedFormat = format;
261
+ }
262
+ return sharedFormat === format;
263
+ })) {
264
+ throw new BedrockError(
265
+ 'Credential requests must all use the same format in this workflow.', {
266
+ name: 'DataError',
267
+ details: {httpStatusCode: 400, public: true}
268
+ });
269
+ }
270
+
271
+ // ensure that the shared format is supported by the workflow
272
+ const supportedFormats = _getSupportedFormats({workflow});
273
+ if(!supportedFormats.has(sharedFormat)) {
274
+ throw new BedrockError(
275
+ `Credential request format "${sharedFormat}" is not supported ` +
276
+ 'by this workflow.', {
277
+ name: 'DataError',
278
+ details: {httpStatusCode: 400, public: true}
279
+ });
280
+ }
281
+
282
+ // ensure every credential request matches against an expected one and none
283
+ // are missing; `expectedCredentialRequests` formats are ignored based on the
284
+ // issuer instance supported formats and have already been checked
285
+ if(!(credentialRequests.length === expectedCredentialRequests.length &&
286
+ credentialRequests.every(cr => expectedCredentialRequests.some(
287
+ expected => _matchCredentialRequest(expected, cr))))) {
288
+ throw new BedrockError(
289
+ 'Unexpected credential request.', {
290
+ name: 'DataError',
291
+ details: {httpStatusCode: 400, public: true}
292
+ });
293
+ }
294
+
295
+ return {format: sharedFormat};
296
+ }
297
+
298
+ function _assertOID4VCISupported({exchange}) {
299
+ if(!exchange.openId?.expectedCredentialRequests) {
300
+ throw new BedrockError('OID4VCI is not supported by this exchange.', {
301
+ name: 'NotSupportedError',
302
+ details: {httpStatusCode: 400, public: true}
303
+ });
304
+ }
305
+ }
306
+
307
+ async function _checkAuthz({req, workflow, exchange}) {
308
+ // optional oauth2 options
309
+ const {oauth2} = exchange.openId;
310
+ const {maxClockSkew} = oauth2;
311
+
312
+ // audience is always the `exchangeId` and cannot be configured; this
313
+ // prevents attacks where access tokens could otherwise be generated
314
+ // if the AS keys were compromised; the `exchangeId` must also be known
315
+ const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
316
+ const audience = exchangeId;
317
+
318
+ // `issuerConfigUrl` is always based off of the `exchangeId` as well
319
+ const parsedIssuer = new URL(exchangeId);
320
+ const issuerConfigUrl =
321
+ `${parsedIssuer.origin}/.well-known/oauth-authorization-server` +
322
+ parsedIssuer.pathname;
323
+
324
+ // FIXME: `allowedAlgorithms` should be computed from `oauth2.keyPair`
325
+ // const allowedAlgorithms =
326
+
327
+ // ensure access token is valid
328
+ await checkAccessToken({req, issuerConfigUrl, maxClockSkew, audience});
329
+ }
330
+
331
+ async function _createExchangeAccessToken({workflow, exchangeRecord}) {
332
+ // FIXME: set `exp` to max of 15 minutes / configured max minutes
333
+ const expires = exchangeRecord.meta.expires;
334
+ const exp = Math.floor(expires.getTime() / 1000);
335
+
336
+ // create access token
337
+ const {exchange} = exchangeRecord;
338
+ const {openId: {oauth2: {keyPair: {privateKeyJwk}}}} = exchange;
339
+ const exchangeId = `${workflow.id}/exchanges/${exchange.id}`;
340
+ const {accessToken, ttl} = await _createOAuth2AccessToken({
341
+ privateKeyJwk, audience: exchangeId, action: 'write', target: exchangeId,
342
+ exp, iss: exchangeId
343
+ });
344
+ return {accessToken, ttl};
345
+ }
346
+
347
+ async function _createOAuth2AccessToken({
348
+ privateKeyJwk, audience, action, target, exp, iss, nbf, typ = 'at+jwt'
349
+ }) {
350
+ const alg = _getAlgFromPrivateKey({privateKeyJwk});
351
+ const scope = `${action}:${target}`;
352
+ const builder = new SignJWT({scope})
353
+ .setProtectedHeader({alg, typ})
354
+ .setIssuer(iss)
355
+ .setAudience(audience);
356
+ let ttl;
357
+ if(exp !== undefined) {
358
+ builder.setExpirationTime(exp);
359
+ ttl = Math.max(0, exp - Math.floor(Date.now() / 1000));
360
+ } else {
361
+ // default to 15 minute expiration time
362
+ builder.setExpirationTime('15m');
363
+ ttl = Math.floor(Date.now() / 1000) + 15 * 60;
364
+ }
365
+ if(nbf !== undefined) {
366
+ builder.setNotBefore(nbf);
367
+ }
368
+ const key = await importJWK({...privateKeyJwk, alg});
369
+ const accessToken = await builder.sign(key);
370
+ return {accessToken, ttl};
371
+ }
372
+
373
+ function _createCredentialConfigurationId({format, credential_definition}) {
374
+ let types = (credential_definition.type ?? credential_definition.types);
375
+ if(types.length > 1) {
376
+ types = types.filter(t => t !== 'VerifiableCredential');
377
+ }
378
+ return types.join('_') + '_' + format;
379
+ }
380
+
381
+ function _createCredentialConfigurations({
382
+ credentialRequest, supportedFormats
383
+ }) {
384
+ const configurations = [];
385
+
386
+ let {format: formats = supportedFormats} = credentialRequest;
387
+ if(!Array.isArray(formats)) {
388
+ formats = [formats];
389
+ }
390
+
391
+ for(const format of formats) {
392
+ const {credential_definition} = credentialRequest;
393
+ const id = _createCredentialConfigurationId({
394
+ format, credential_definition
395
+ });
396
+ const configuration = {format, credential_definition};
397
+ // FIXME: if `jwtDidProofRequest` exists in (any) step in the exchange,
398
+ // then must include:
399
+ /*
400
+ "proof_types_supported": {
401
+ "jwt": {
402
+ "proof_signing_alg_values_supported": [
403
+ "ES256"
404
+ ]
405
+ }
406
+ }
407
+ */
408
+ configurations.push({id, configuration});
409
+ }
410
+
411
+ return configurations;
412
+ }
413
+
414
+ function _getAlgFromPrivateKey({privateKeyJwk}) {
415
+ if(privateKeyJwk.alg) {
416
+ return privateKeyJwk.alg;
417
+ }
418
+ if(privateKeyJwk.kty === 'EC' && privateKeyJwk.crv) {
419
+ if(privateKeyJwk.crv.startsWith('P-')) {
420
+ return `ES${privateKeyJwk.crv.slice(2)}`;
421
+ }
422
+ if(privateKeyJwk.crv === 'secp256k1') {
423
+ return 'ES256K';
424
+ }
425
+ }
426
+ if(privateKeyJwk.kty === 'OKP' && privateKeyJwk.crv?.startsWith('Ed')) {
427
+ return 'EdDSA';
428
+ }
429
+ if(privateKeyJwk.kty === 'RSA') {
430
+ return 'PS256';
431
+ }
432
+ return 'invalid';
433
+ }
434
+
435
+ function _getSupportedFormats({workflow}) {
436
+ // get all supported formats from available issuer instances; for simple
437
+ // workflow configs, a single issuer instance is used
438
+ const supportedFormats = new Set();
439
+ const issuerInstances = getWorkflowIssuerInstances({workflow});
440
+ issuerInstances.forEach(
441
+ instance => instance.supportedFormats.forEach(
442
+ supportedFormats.add, supportedFormats));
443
+ return supportedFormats;
444
+ }
445
+
446
+ function _matchCredentialRequest(expected, cr) {
447
+ const {credential_definition: {'@context': c1, type: t1}} = expected;
448
+ const {credential_definition: {'@context': c2, type: t2}} = cr;
449
+ // contexts must match exact order but types can have different order
450
+ return (c1.length === c2.length && t1.length === t2.length &&
451
+ c1.every((c, i) => c === c2[i]) && t1.every(t => t2.some(x => t === x)));
452
+ }
453
+
454
+ function _normalizeCredentialDefinitionTypes({credentialRequests}) {
455
+ // normalize credential requests to use `type` instead of `types`
456
+ for(const cr of credentialRequests) {
457
+ if(cr?.credential_definition?.types) {
458
+ if(!cr?.credential_definition?.type) {
459
+ cr.credential_definition.type = cr.credential_definition.types;
460
+ }
461
+ delete cr.credential_definition.types;
462
+ }
463
+ }
464
+ }
465
+
466
+ async function _requestDidProof({res, exchangeRecord}) {
467
+ /* `9.4 Credential Issuer-provided nonce` allows the credential
468
+ issuer infrastructure to provide the nonce via an error:
469
+
470
+ HTTP/1.1 400 Bad Request
471
+ Content-Type: application/json
472
+ Cache-Control: no-store
473
+
474
+ {
475
+ "error": "invalid_or_missing_proof"
476
+ "error_description":
477
+ "Credential issuer requires proof element in Credential Request"
478
+ "c_nonce": "8YE9hCnyV2",
479
+ "c_nonce_expires_in": 86400
480
+ }*/
481
+
482
+ /* OID4VCI exchanges themselves are not replayable and single-step, so the
483
+ challenge to be signed is just the exchange ID itself. An exchange cannot
484
+ be reused and neither can a challenge. */
485
+ const {exchange, meta: {expires}} = exchangeRecord;
486
+ const ttl = Math.floor((expires.getTime() - Date.now()) / 1000);
487
+
488
+ res.status(400).json({
489
+ error: 'invalid_or_missing_proof',
490
+ error_description:
491
+ 'Credential issuer requires proof element in Credential Request',
492
+ // use exchange ID
493
+ c_nonce: exchange.id,
494
+ // use exchange expiration period
495
+ c_nonce_expires_in: ttl
496
+ });
497
+ }
498
+
499
+ async function _requestOID4VP({authorizationRequest, res}) {
500
+ /* Error thrown when OID4VP is required to complete OID4VCI:
501
+
502
+ HTTP/1.1 400 Bad Request
503
+ Content-Type: application/json
504
+ Cache-Control: no-store
505
+
506
+ {
507
+ "error": "presentation_required"
508
+ "error_description":
509
+ "Credential issuer requires presentation before Credential Request"
510
+ "authorization_request": {
511
+ "response_type": "vp_token",
512
+ "presentation_definition": {
513
+ id: "<urn:uuid>",
514
+ input_descriptors: {...}
515
+ },
516
+ "response_mode": "direct_post"
517
+ }
518
+ }*/
519
+
520
+ /* OID4VCI exchanges themselves are not replayable and single-step, so the
521
+ challenge to be signed is just the exchange ID itself. An exchange cannot
522
+ be reused and neither can a challenge. */
523
+
524
+ res.status(400).json({
525
+ error: 'presentation_required',
526
+ error_description:
527
+ 'Credential issuer requires presentation before Credential Request',
528
+ authorization_request: authorizationRequest
529
+ });
530
+ }