@bedrock/vc-delivery 5.0.1 → 5.2.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,330 @@
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 {evaluateTemplate, unenvelopePresentation} from '../helpers.js';
7
+ import {
8
+ presentationSubmission as presentationSubmissionSchema,
9
+ verifiablePresentation as verifiablePresentationSchema
10
+ } from '../../schemas/bedrock-vc-workflow.js';
11
+ import {compile} from '@bedrock/validation';
12
+ import {klona} from 'klona';
13
+ import {oid4vp} from '@digitalbazaar/oid4-client';
14
+ import {verify} from '../verify.js';
15
+
16
+ const {util: {BedrockError}} = bedrock;
17
+
18
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
19
+
20
+ const VALIDATORS = {
21
+ presentation: null,
22
+ presentationSubmission: null
23
+ };
24
+
25
+ bedrock.events.on('bedrock.init', () => {
26
+ // create validators for x-www-form-urlencoded parsed data
27
+ VALIDATORS.presentation = compile({schema: verifiablePresentationSchema()});
28
+ VALIDATORS.presentationSubmission = compile({
29
+ schema: presentationSubmissionSchema
30
+ });
31
+ });
32
+
33
+ export async function getAuthorizationRequest({req}) {
34
+ const {config: workflow} = req.serviceObject;
35
+ const exchangeRecord = await req.getExchange();
36
+ let {exchange} = exchangeRecord;
37
+ let step;
38
+
39
+ while(true) {
40
+ // exchange step required for OID4VP
41
+ const currentStep = exchange.step;
42
+ if(!currentStep) {
43
+ _throwUnsupportedProtocol();
44
+ }
45
+
46
+ step = workflow.steps[exchange.step];
47
+ if(step.stepTemplate) {
48
+ // generate step from the template; assume the template type is
49
+ // `jsonata` per the JSON schema
50
+ step = await evaluateTemplate(
51
+ {workflow, exchange, typedTemplate: step.stepTemplate});
52
+ if(Object.keys(step).length === 0) {
53
+ throw new BedrockError('Could not create authorization request.', {
54
+ name: 'DataError',
55
+ details: {httpStatusCode: 500, public: true}
56
+ });
57
+ }
58
+ }
59
+
60
+ // step must have `openId` to perform OID4VP
61
+ if(!step.openId) {
62
+ _throwUnsupportedProtocol();
63
+ }
64
+
65
+ let updateExchange = false;
66
+
67
+ if(exchange.state === 'pending') {
68
+ exchange.state = 'active';
69
+ updateExchange = true;
70
+ }
71
+
72
+ // get authorization request
73
+ let authorizationRequest = step.openId.authorizationRequest;
74
+ if(!authorizationRequest) {
75
+ // create authorization request...
76
+ // get variable name for authorization request
77
+ const authzReqName = step.openId.createAuthorizationRequest;
78
+ if(authzReqName === undefined) {
79
+ _throwUnsupportedProtocol();
80
+ }
81
+
82
+ // create or get cached authorization request
83
+ authorizationRequest = exchange.variables?.[authzReqName];
84
+ if(!authorizationRequest) {
85
+ const {verifiablePresentationRequest} = step;
86
+ authorizationRequest = oid4vp.fromVpr({verifiablePresentationRequest});
87
+
88
+ // add / override params from step `openId` information
89
+ const {
90
+ client_id, client_id_scheme,
91
+ client_metadata, client_metadata_uri,
92
+ nonce, response_uri
93
+ } = step.openId || {};
94
+ if(client_id) {
95
+ authorizationRequest.client_id = client_id;
96
+ } else {
97
+ authorizationRequest.client_id =
98
+ `${workflow.id}/exchanges/${exchange.id}` +
99
+ '/openid/client/authorization/response';
100
+ }
101
+ if(client_id_scheme) {
102
+ authorizationRequest.client_id_scheme = client_id_scheme;
103
+ } else if(authorizationRequest.client_id_scheme === undefined) {
104
+ authorizationRequest.client_id_scheme = 'redirect_uri';
105
+ }
106
+ if(client_metadata) {
107
+ authorizationRequest.client_metadata = klona(client_metadata);
108
+ } else if(client_metadata_uri) {
109
+ authorizationRequest.client_metadata_uri = client_metadata_uri;
110
+ } else {
111
+ // auto-generate client_metadata
112
+ authorizationRequest.client_metadata = _createClientMetaData();
113
+ }
114
+ if(nonce) {
115
+ authorizationRequest.nonce = nonce;
116
+ } else if(authorizationRequest.nonce === undefined) {
117
+ // if no nonce has been set for the authorization request, use the
118
+ // exchange ID
119
+ authorizationRequest.nonce = exchange.id;
120
+ }
121
+ if(response_uri) {
122
+ authorizationRequest.response_uri = response_uri;
123
+ } else if(authorizationRequest.response_mode === 'direct_post' &&
124
+ authorizationRequest.client_id_scheme === 'redirect_uri') {
125
+ // `authorizationRequest` uses `direct_post` so force client ID to
126
+ // be the exchange response URL per "Note" here:
127
+ // eslint-disable-next-line max-len
128
+ // https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#section-6.2
129
+ authorizationRequest.response_uri = authorizationRequest.client_id;
130
+ }
131
+
132
+ // store generated authorization request
133
+ updateExchange = true;
134
+ if(!exchange.variables) {
135
+ exchange.variables = {};
136
+ }
137
+ exchange.variables[authzReqName] = authorizationRequest;
138
+ }
139
+ }
140
+
141
+ if(updateExchange) {
142
+ exchange.sequence++;
143
+ try {
144
+ await exchanges.update({workflowId: workflow.id, exchange});
145
+ } catch(e) {
146
+ if(e.name !== 'InvalidStateError') {
147
+ // unrecoverable error
148
+ throw e;
149
+ }
150
+ // get exchange and loop to try again on `InvalidStateError`
151
+ const record = await exchanges.get(
152
+ {workflowId: workflow.id, id: exchange.id});
153
+ ({exchange} = record);
154
+ continue;
155
+ }
156
+ }
157
+
158
+ return {authorizationRequest, exchange, step};
159
+ }
160
+ }
161
+
162
+ export async function processAuthorizationResponse({req}) {
163
+ const {
164
+ presentation, envelope, presentationSubmission
165
+ } = await _parseAuthorizationResponse({req});
166
+
167
+ const {config: workflow} = req.serviceObject;
168
+ const exchangeRecord = await req.getExchange();
169
+ let {exchange} = exchangeRecord;
170
+
171
+ // get authorization request and updated exchange associated with exchange
172
+ const arRequest = await getAuthorizationRequest({req});
173
+ const {authorizationRequest, step} = arRequest;
174
+ ({exchange} = arRequest);
175
+
176
+ // FIXME: check the VP against the presentation submission if requested
177
+ // FIXME: check the VP against "trustedIssuer" in VPR, if provided
178
+ const {presentationSchema} = step;
179
+ if(presentationSchema) {
180
+ // if the VP is enveloped, validate the contents of the envelope
181
+ const toValidate = envelope ? envelope.contents : presentation;
182
+
183
+ // validate the received VP / envelope contents
184
+ const {jsonSchema: schema} = presentationSchema;
185
+ const validate = compile({schema});
186
+ const {valid, error} = validate(toValidate);
187
+ if(!valid) {
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ // verify the received VP
193
+ const {verifiablePresentationRequest} = await oid4vp.toVpr(
194
+ {authorizationRequest});
195
+ const {allowUnprotectedPresentation = false} = step;
196
+ const verifyResult = await verify({
197
+ workflow,
198
+ verifiablePresentationRequest,
199
+ presentation,
200
+ allowUnprotectedPresentation,
201
+ expectedChallenge: authorizationRequest.nonce
202
+ });
203
+ const {verificationMethod} = verifyResult;
204
+
205
+ // store VP results in variables associated with current step
206
+ const currentStep = exchange.step;
207
+ if(!exchange.variables.results) {
208
+ exchange.variables.results = {};
209
+ }
210
+ const results = {
211
+ // common use case of DID Authentication; provide `did` for ease
212
+ // of use in template
213
+ did: verificationMethod?.controller || null,
214
+ verificationMethod,
215
+ verifiablePresentation: presentation,
216
+ openId: {
217
+ authorizationRequest,
218
+ presentationSubmission
219
+ }
220
+ };
221
+ if(envelope) {
222
+ // normalize VP from inside envelope to `verifiablePresentation`
223
+ results.envelopedPresentation = presentation;
224
+ results.verifiablePresentation = verifyResult
225
+ .presentationResult.presentation;
226
+ }
227
+ exchange.variables.results[currentStep] = results;
228
+ exchange.sequence++;
229
+
230
+ // if there is something to issue, update exchange, do not complete it
231
+ const {credentialTemplates = []} = workflow;
232
+ if(credentialTemplates?.length > 0 &&
233
+ (exchange.state === 'pending' || exchange.state === 'active')) {
234
+ // ensure exchange state is set to `active` (will be rejected as a
235
+ // conflict if the state in database at update time isn't `pending` or
236
+ // `active`)
237
+ exchange.state = 'active';
238
+ await exchanges.update({workflowId: workflow.id, exchange});
239
+ } else {
240
+ // mark exchange complete
241
+ await exchanges.complete({workflowId: workflow.id, exchange});
242
+ }
243
+
244
+ const result = {};
245
+
246
+ // include `redirect_uri` if specified in step
247
+ const redirect_uri = step.openId?.redirect_uri;
248
+ if(redirect_uri) {
249
+ result.redirect_uri = redirect_uri;
250
+ }
251
+
252
+ return result;
253
+ }
254
+
255
+ function _createClientMetaData() {
256
+ // return default supported `vp_formats`
257
+ return {
258
+ vp_formats: {
259
+ jwt_vp: {
260
+ alg: ['EdDSA', 'Ed25519', 'ES256', 'ES384']
261
+ },
262
+ ldp_vp: {
263
+ proof_type: [
264
+ 'ecdsa-rdfc-2019',
265
+ 'eddsa-rdfc-2022',
266
+ 'Ed25519Signature2020'
267
+ ]
268
+ }
269
+ }
270
+ };
271
+ }
272
+
273
+ async function _parseAuthorizationResponse({req}) {
274
+ // get JSON `vp_token` and `presentation_submission`
275
+ const {vp_token, presentation_submission} = req.body;
276
+
277
+ // JSON parse and validate `vp_token` and `presentation_submission`
278
+ let presentation = _jsonParse(vp_token, 'vp_token');
279
+ const presentationSubmission = _jsonParse(
280
+ presentation_submission, 'presentation_submission');
281
+ _validate(VALIDATORS.presentationSubmission, presentationSubmission);
282
+ let envelope;
283
+ if(typeof presentation === 'string') {
284
+ // handle enveloped presentation
285
+ const {
286
+ envelope: raw, presentation: contents, format
287
+ } = await unenvelopePresentation({
288
+ envelopedPresentation: presentation,
289
+ // FIXME: check presentationSubmission for VP format
290
+ format: 'jwt_vc_json-ld'
291
+ });
292
+ _validate(VALIDATORS.presentation, contents);
293
+ presentation = {
294
+ '@context': VC_CONTEXT_2,
295
+ id: `data:${format},${raw}`,
296
+ type: 'EnvelopedVerifiablePresentation'
297
+ };
298
+ envelope = {raw, contents, format};
299
+ } else {
300
+ _validate(VALIDATORS.presentation, presentation);
301
+ }
302
+
303
+ return {presentation, envelope, presentationSubmission};
304
+ }
305
+
306
+ function _jsonParse(x, name) {
307
+ try {
308
+ return JSON.parse(x);
309
+ } catch(cause) {
310
+ throw new BedrockError(`Could not parse "${name}".`, {
311
+ name: 'DataError',
312
+ details: {httpStatusCode: 400, public: true},
313
+ cause
314
+ });
315
+ }
316
+ }
317
+
318
+ function _throwUnsupportedProtocol() {
319
+ throw new BedrockError('OID4VP is not supported by this exchange.', {
320
+ name: 'NotSupportedError',
321
+ details: {httpStatusCode: 400, public: true}
322
+ });
323
+ }
324
+
325
+ function _validate(validator, data) {
326
+ const result = validator(data);
327
+ if(!result.valid) {
328
+ throw result.error;
329
+ }
330
+ }
package/lib/vcapi.js CHANGED
@@ -4,7 +4,10 @@
4
4
  import * as bedrock from '@bedrock/core';
5
5
  import * as exchanges from './exchanges.js';
6
6
  import {createChallenge as _createChallenge, verify} from './verify.js';
7
- import {evaluateTemplate, unenvelopePresentation} from './helpers.js';
7
+ import {
8
+ evaluateTemplate, generateRandom, unenvelopePresentation
9
+ } from './helpers.js';
10
+ import {exportJWK, generateKeyPair, importJWK} from 'jose';
8
11
  import {compile} from '@bedrock/validation';
9
12
  import {issue} from './issue.js';
10
13
  import {klona} from 'klona';
@@ -13,6 +16,79 @@ import {logger} from './logger.js';
13
16
  const {util: {BedrockError}} = bedrock;
14
17
 
15
18
  const MAXIMUM_STEPS = 100;
19
+ const FIFTEEN_MINUTES = 60 * 15;
20
+
21
+ export async function createExchange({workflow, exchange}) {
22
+ const {
23
+ ttl = FIFTEEN_MINUTES, openId, variables = {},
24
+ // allow steps to be skipped by creator as needed
25
+ step = workflow.initialStep
26
+ } = exchange;
27
+
28
+ // validate exchange step, if given
29
+ if(step && !(step in workflow.steps)) {
30
+ throw new BedrockError(`Undefined step "${step}".`, {
31
+ name: 'DataError',
32
+ details: {httpStatusCode: 400, public: true}
33
+ });
34
+ }
35
+
36
+ if(openId) {
37
+ // either issuer instances or a single issuer zcap be given if
38
+ // any expected credential requests are given
39
+ const {expectedCredentialRequests} = openId;
40
+ if(expectedCredentialRequests &&
41
+ !(workflow.issuerInstances || workflow.zcaps.issue)) {
42
+ throw new BedrockError(
43
+ 'Credential requests are not supported by this workflow.', {
44
+ name: 'DataError',
45
+ details: {httpStatusCode: 400, public: true}
46
+ });
47
+ }
48
+
49
+ // perform key generation if requested
50
+ if(openId.oauth2?.generateKeyPair) {
51
+ const {oauth2} = openId;
52
+ const {algorithm} = oauth2.generateKeyPair;
53
+ const kp = await generateKeyPair(algorithm, {extractable: true});
54
+ const [privateKeyJwk, publicKeyJwk] = await Promise.all([
55
+ exportJWK(kp.privateKey),
56
+ exportJWK(kp.publicKey),
57
+ ]);
58
+ oauth2.keyPair = {privateKeyJwk, publicKeyJwk};
59
+ delete oauth2.generateKeyPair;
60
+ } else {
61
+ // ensure key pair can be imported
62
+ try {
63
+ const {oauth2: {keyPair}} = openId;
64
+ await Promise.all([
65
+ importJWK(keyPair.privateKeyJwk),
66
+ importJWK(keyPair.publicKeyJwk)
67
+ ]);
68
+ } catch(e) {
69
+ throw new BedrockError(
70
+ 'Could not import OpenID OAuth2 key pair.', {
71
+ name: 'DataError',
72
+ details: {httpStatusCode: 400, public: true},
73
+ cause: e
74
+ });
75
+ }
76
+ }
77
+ }
78
+
79
+ // insert exchange
80
+ const {id: workflowId} = workflow;
81
+ exchange = {
82
+ id: await generateRandom(),
83
+ ttl,
84
+ variables,
85
+ openId,
86
+ step
87
+ };
88
+ await exchanges.insert({workflowId, exchange});
89
+ // FIXME: run parallel process to pre-warm cache with new exchange record
90
+ return exchange;
91
+ }
16
92
 
17
93
  export async function processExchange({req, res, workflow, exchange}) {
18
94
  // get any `verifiablePresentation` from the body...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrock/vc-delivery",
3
- "version": "5.0.1",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "description": "Bedrock Verifiable Credential Delivery",
6
6
  "main": "./lib/index.js",