@bedrock/vc-delivery 5.0.0 → 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.
package/lib/helpers.js CHANGED
@@ -2,15 +2,15 @@
2
2
  * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
4
  import * as bedrock from '@bedrock/core';
5
+ import * as vcjwt from './vcjwt.js';
5
6
  import {decodeId, generateId} from 'bnid';
6
- import {decodeJwt} from 'jose';
7
7
  import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
8
8
  import {httpsAgent} from '@bedrock/https-agent';
9
9
  import jsonata from 'jsonata';
10
10
  import {serviceAgents} from '@bedrock/service-agent';
11
11
  import {ZcapClient} from '@digitalbazaar/ezcap';
12
12
 
13
- const {config} = bedrock;
13
+ const {config, util: {BedrockError}} = bedrock;
14
14
 
15
15
  export async function evaluateTemplate({
16
16
  workflow, exchange, typedTemplate
@@ -104,29 +104,66 @@ export function decodeLocalId({localId} = {}) {
104
104
  }));
105
105
  }
106
106
 
107
- export async function unenvelopeCredential({envelopedCredential} = {}) {
108
- let credential;
109
- const {id} = envelopedCredential;
110
- if(id?.startsWith('data:application/jwt,')) {
111
- const format = 'application/jwt';
112
- const jwt = id.slice('data:application/jwt,'.length);
113
- const claimset = decodeJwt(jwt);
114
- // FIXME: perform various field mappings as needed
115
- console.log('VC-JWT claimset', credential);
116
- return {credential: claimset.vc, format};
107
+ export async function unenvelopeCredential({
108
+ envelopedCredential, format
109
+ } = {}) {
110
+ const result = _getEnvelope({envelope: envelopedCredential, format});
111
+
112
+ // only supported format is VC-JWT at this time
113
+ const credential = vcjwt.decodeVCJWTCredential({jwt: result.envelope});
114
+ return {credential, ...result};
115
+ }
116
+
117
+ export async function unenvelopePresentation({
118
+ envelopedPresentation, format
119
+ } = {}) {
120
+ const result = _getEnvelope({envelope: envelopedPresentation, format});
121
+
122
+ // only supported format is VC-JWT at this time
123
+ const presentation = vcjwt.decodeVCJWTPresentation({jwt: result.envelope});
124
+
125
+ // unenvelope any VCs in the presentation
126
+ let {verifiableCredential = []} = presentation;
127
+ if(!Array.isArray(verifiableCredential)) {
128
+ verifiableCredential = [verifiableCredential];
117
129
  }
118
- throw new Error('Not implemented.');
130
+ if(verifiableCredential.length > 0) {
131
+ presentation.verifiableCredential = await Promise.all(
132
+ verifiableCredential.map(async vc => {
133
+ if(vc?.type !== 'EnvelopedVerifiableCredential') {
134
+ return vc;
135
+ }
136
+ const {credential} = await unenvelopeCredential({
137
+ envelopedCredential: vc
138
+ });
139
+ return credential;
140
+ }));
141
+ }
142
+ return {presentation, ...result};
119
143
  }
120
144
 
121
- export async function unenvelopePresentation({envelopedPresentation} = {}) {
122
- const {id} = envelopedPresentation;
123
- if(id?.startsWith('data:application/jwt,')) {
124
- const format = 'application/jwt';
125
- const jwt = id.slice('data:application/jwt,'.length);
126
- const claimset = decodeJwt(jwt);
127
- // FIXME: perform various field mappings as needed
128
- console.log('VC-JWT claimset', claimset);
129
- return {presentation: claimset.vp, format};
145
+ function _getEnvelope({envelope, format}) {
146
+ const isString = typeof envelope === 'string';
147
+ if(isString) {
148
+ // supported formats
149
+ if(format === 'application/jwt' || format === 'jwt_vc_json-ld') {
150
+ format = 'application/jwt';
151
+ }
152
+ } else {
153
+ const {id} = envelope;
154
+ if(id?.startsWith('data:application/jwt,')) {
155
+ format = 'application/jwt';
156
+ envelope = id.slice('data:application/jwt,'.length);
157
+ }
158
+ }
159
+
160
+ if(format === 'application/jwt' && envelope !== undefined) {
161
+ return {envelope, format};
130
162
  }
131
- throw new Error('Not implemented.');
163
+
164
+ throw new BedrockError(
165
+ `Unsupported credential or presentation envelope format "${format}".`, {
166
+ name: 'NotSupportedError',
167
+ details: {httpStatusCode: 400, public: true}
168
+ });
132
169
  }
package/lib/http.js CHANGED
@@ -1,24 +1,21 @@
1
1
  /*!
2
2
  * Copyright (c) 2018-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
- import * as _openId from './openId.js';
5
4
  import * as bedrock from '@bedrock/core';
6
5
  import * as exchanges from './exchanges.js';
6
+ import * as oid4 from './oid4/http.js';
7
+ import {createExchange, processExchange} from './vcapi.js';
7
8
  import {
8
9
  createExchangeBody, useExchangeBody
9
10
  } from '../schemas/bedrock-vc-workflow.js';
10
- import {exportJWK, generateKeyPair, importJWK} from 'jose';
11
- import {generateRandom, getWorkflowId} from './helpers.js';
12
11
  import {metering, middleware} from '@bedrock/service-core';
13
12
  import {asyncHandler} from '@bedrock/express';
14
13
  import bodyParser from 'body-parser';
15
14
  import cors from 'cors';
15
+ import {getWorkflowId} from './helpers.js';
16
16
  import {logger} from './logger.js';
17
- import {processExchange} from './vcapi.js';
18
17
  import {createValidateMiddleware as validate} from '@bedrock/validation';
19
18
 
20
- const {util: {BedrockError}} = bedrock;
21
-
22
19
  // FIXME: remove and apply at top-level application
23
20
  bedrock.events.on('bedrock-express.configure.bodyParser', app => {
24
21
  app.use(bodyParser.json({
@@ -73,75 +70,15 @@ export async function addRoutes({app, service} = {}) {
73
70
  // FIXME: check available storage via meter before allowing operation
74
71
 
75
72
  try {
76
- const {config} = req.serviceObject;
73
+ const {config: workflow} = req.serviceObject;
77
74
  const {
78
75
  ttl, openId, variables = {},
79
76
  // allow steps to be skipped by creator as needed
80
- step = config.initialStep
77
+ step = workflow.initialStep
81
78
  } = req.body;
82
-
83
- // validate exchange step, if given
84
- if(step && !(step in config.steps)) {
85
- throw new BedrockError(`Undefined step "${step}".`, {
86
- name: 'DataError',
87
- details: {httpStatusCode: 400, public: true}
88
- });
89
- }
90
-
91
- if(openId) {
92
- // either issuer instances or a single issuer zcap be given if
93
- // any expected credential requests are given
94
- const {expectedCredentialRequests} = openId;
95
- if(expectedCredentialRequests &&
96
- !(config.issuerInstances || config.zcaps.issue)) {
97
- throw new BedrockError(
98
- 'Credential requests are not supported by this workflow.', {
99
- name: 'DataError',
100
- details: {httpStatusCode: 400, public: true}
101
- });
102
- }
103
-
104
- // perform key generation if requested
105
- if(openId.oauth2?.generateKeyPair) {
106
- const {oauth2} = openId;
107
- const {algorithm} = oauth2.generateKeyPair;
108
- const kp = await generateKeyPair(algorithm, {extractable: true});
109
- const [privateKeyJwk, publicKeyJwk] = await Promise.all([
110
- exportJWK(kp.privateKey),
111
- exportJWK(kp.publicKey),
112
- ]);
113
- oauth2.keyPair = {privateKeyJwk, publicKeyJwk};
114
- delete oauth2.generateKeyPair;
115
- } else {
116
- // ensure key pair can be imported
117
- try {
118
- const {oauth2: {keyPair}} = openId;
119
- await Promise.all([
120
- importJWK(keyPair.privateKeyJwk),
121
- importJWK(keyPair.publicKeyJwk)
122
- ]);
123
- } catch(e) {
124
- throw new BedrockError(
125
- 'Could not import OpenID OAuth2 key pair.', {
126
- name: 'DataError',
127
- details: {httpStatusCode: 400, public: true},
128
- cause: e
129
- });
130
- }
131
- }
132
- }
133
-
134
- // insert exchange
135
- const {id: workflowId} = config;
136
- const exchange = {
137
- id: await generateRandom(),
138
- ttl,
139
- variables,
140
- openId,
141
- step
142
- };
143
- await exchanges.insert({workflowId, exchange});
144
- const location = `${workflowId}/exchanges/${exchange.id}`;
79
+ const exchange = {ttl, openId, variables, step};
80
+ const {id} = await createExchange({workflow, exchange});
81
+ const location = `${workflow.id}/exchanges/${id}`;
145
82
  res.status(204).location(location).send();
146
83
  } catch(error) {
147
84
  logger.error(error.message, {error});
@@ -180,7 +117,7 @@ export async function addRoutes({app, service} = {}) {
180
117
  await processExchange({req, res, workflow, exchange});
181
118
  }));
182
119
 
183
- // create OID4VCI routes to be used with each individual exchange
184
- await _openId.createRoutes(
120
+ // create OID4* routes to be used with each individual exchange
121
+ await oid4.createRoutes(
185
122
  {app, exchangeRoute: routes.exchange, getConfigMiddleware, getExchange});
186
123
  }
@@ -0,0 +1,314 @@
1
+ /*!
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as oid4vci from './oid4vci.js';
5
+ import * as oid4vp from './oid4vp.js';
6
+ import {
7
+ openIdAuthorizationResponseBody,
8
+ openIdBatchCredentialBody,
9
+ openIdCredentialBody,
10
+ openIdTokenBody
11
+ } from '../../schemas/bedrock-vc-workflow.js';
12
+ import {asyncHandler} from '@bedrock/express';
13
+ import bodyParser from 'body-parser';
14
+ import cors from 'cors';
15
+ import {UnsecuredJWT} from 'jose';
16
+ import {createValidateMiddleware as validate} from '@bedrock/validation';
17
+
18
+ /* NOTE: Parts of the OID4VCI design imply tight integration between the
19
+ authorization server and the credential issuance / delivery server. This
20
+ file provides the routes for both and treats them as integrated; supporting
21
+ the OID4VCI pre-authz code flow only as a result. However, we also try to
22
+ avoid tight-coupling where possible to enable the non-pre-authz code flow
23
+ that would use, somehow, a separate authorization server.
24
+
25
+ One tight coupling we try to avoid involves the option where the authorization
26
+ server generates the challenge nonce to be signed in a DID proof, but the
27
+ credential delivery server is the system responsible for checking and tracking
28
+ this challenge. The Credential Delivery server cannot know the challenge is
29
+ authentic without breaking some abstraction around how the Authorization
30
+ Server is implemented behind its API. Here we do not implement this option,
31
+ instead, if a challenge is required, the credential delivery server will send
32
+ an error with the challenge nonce if one was not provided in the payload to the
33
+ credential endpoint. This error follows the OID4VCI spec and avoids this
34
+ particular tight coupling.
35
+
36
+ Other tight couplings cannot be avoided at this time -- such as the fact that
37
+ the credential endpoint is specified in the authorization server's metadata;
38
+ this creates challenges for SaaS based solutions and for issuers that want to
39
+ use multiple different Issuance / Delivery server backends. We solve these
40
+ challenges by using the "pre-authorized code" flows and effectively
41
+ instantiating a new authorization server instance per VC exchange. */
42
+
43
+ // creates OID4VCI Authorization Server + Credential Delivery Server
44
+ // endpoints for each individual exchange
45
+ export async function createRoutes({
46
+ app, exchangeRoute, getConfigMiddleware, getExchange
47
+ } = {}) {
48
+ const openIdRoute = `${exchangeRoute}/openid`;
49
+ const routes = {
50
+ // OID4VCI routes
51
+ asMetadata1: `/.well-known/oauth-authorization-server${exchangeRoute}`,
52
+ asMetadata2: `${exchangeRoute}/.well-known/oauth-authorization-server`,
53
+ ciMetadata1: `/.well-known/openid-credential-issuer${exchangeRoute}`,
54
+ ciMetadata2: `${exchangeRoute}/.well-known/openid-credential-issuer`,
55
+ batchCredential: `${openIdRoute}/batch_credential`,
56
+ credential: `${openIdRoute}/credential`,
57
+ token: `${openIdRoute}/token`,
58
+ jwks: `${openIdRoute}/jwks`,
59
+ // OID4VP routes
60
+ authorizationRequest: `${openIdRoute}/client/authorization/request`,
61
+ authorizationResponse: `${openIdRoute}/client/authorization/response`
62
+ };
63
+
64
+ // urlencoded body parser (extended=true for rich JSON-like representation)
65
+ const urlencoded = bodyParser.urlencoded({extended: true});
66
+
67
+ /* Note: The well-known metadata paths for the OID4VCI spec have been
68
+ specified in at least two different ways over time, including
69
+ `<path>/.well-known/...` and `/.well-known/.../<path>`, so they are provided
70
+ here using both approaches to maximize interoperability with clients. It
71
+ is also notable that some versions of the spec have indicated that the
72
+ credential issuer metadata should be expressed in the authorization server
73
+ metadata and others have indicated that they can be separate; since our
74
+ approach virtualizes both the AS and CI anyway, it is all served together. */
75
+
76
+ // an authorization server meta data endpoint
77
+ // serves `.well-known` oauth2 AS config for each exchange; each config is
78
+ // based on the workflow used to create the exchange
79
+ app.get(
80
+ routes.asMetadata1,
81
+ cors(),
82
+ getConfigMiddleware,
83
+ getExchange,
84
+ asyncHandler(async (req, res) => {
85
+ res.json(await oid4vci.getAuthorizationServerConfig({req}));
86
+ }));
87
+
88
+ // an authorization server meta data endpoint
89
+ // serves `.well-known` oauth2 AS config for each exchange; each config is
90
+ // based on the workflow used to create the exchange
91
+ app.get(
92
+ routes.asMetadata2,
93
+ cors(),
94
+ getConfigMiddleware,
95
+ getExchange,
96
+ asyncHandler(async (req, res) => {
97
+ res.json(await oid4vci.getAuthorizationServerConfig({req}));
98
+ }));
99
+
100
+ // a credential issuer meta data endpoint
101
+ // serves `.well-known` oauth2 AS / CI config for each exchange; each config
102
+ // is based on the workflow used to create the exchange
103
+ app.get(
104
+ routes.ciMetadata1,
105
+ cors(),
106
+ getConfigMiddleware,
107
+ getExchange,
108
+ asyncHandler(async (req, res) => {
109
+ res.json(await oid4vci.getCredentialIssuerConfig({req}));
110
+ }));
111
+
112
+ // a credential issuer meta data endpoint
113
+ // serves `.well-known` oauth2 AS / CI config for each exchange; each config
114
+ // is based on the workflow used to create the exchange
115
+ app.get(
116
+ routes.ciMetadata2,
117
+ cors(),
118
+ getConfigMiddleware,
119
+ getExchange,
120
+ asyncHandler(async (req, res) => {
121
+ res.json(await oid4vci.getCredentialIssuerConfig({req}));
122
+ }));
123
+
124
+ // an authorization server endpoint
125
+ // serves JWKs associated with each exchange; JWKs are stored with the
126
+ // workflow used to create the exchange
127
+ app.get(
128
+ routes.jwks,
129
+ cors(),
130
+ getExchange,
131
+ asyncHandler(async (req, res) => {
132
+ // serve exchange's public key(s)
133
+ const keys = await oid4vci.getJwks({req});
134
+ res.json({keys});
135
+ }));
136
+
137
+ // an authorization server endpoint
138
+ // handles pre-authorization code exchange for access token; only supports
139
+ // pre-authorization code grant type
140
+ app.options(routes.token, cors());
141
+ app.post(
142
+ routes.token,
143
+ cors(),
144
+ urlencoded,
145
+ validate({bodySchema: openIdTokenBody}),
146
+ getConfigMiddleware,
147
+ getExchange,
148
+ asyncHandler(async (req, res) => {
149
+ const response = await oid4vci.processAccessTokenRequest({req, res});
150
+ res.json(response);
151
+ }));
152
+
153
+ // a credential delivery server endpoint
154
+ // receives a credential request and returns VCs
155
+ app.options(routes.credential, cors());
156
+ app.post(
157
+ routes.credential,
158
+ cors(),
159
+ validate({bodySchema: openIdCredentialBody}),
160
+ getConfigMiddleware,
161
+ getExchange,
162
+ asyncHandler(async (req, res) => {
163
+ /* Clients must POST, e.g.:
164
+ POST /credential HTTP/1.1
165
+ Host: server.example.com
166
+ Content-Type: application/json
167
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
168
+
169
+ {
170
+ "format": "ldp_vc",
171
+ "credential_definition": {
172
+ "@context": [
173
+ "https://www.w3.org/2018/credentials/v1",
174
+ "https://www.w3.org/2018/credentials/examples/v1"
175
+ ],
176
+ "type": [
177
+ "VerifiableCredential",
178
+ "UniversityDegreeCredential"
179
+ ]
180
+ },
181
+ "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
182
+ "proof": {
183
+ "proof_type": "jwt",
184
+ "jwt": "eyJra...nOzM"
185
+ }
186
+ }
187
+ */
188
+ const result = await oid4vci.processCredentialRequests({
189
+ req, res, isBatchRequest: false
190
+ });
191
+ if(!result) {
192
+ // DID proof request response sent
193
+ return;
194
+ }
195
+
196
+ /* Note: The `/credential` route only supports sending a single VC;
197
+ assume here that this workflow is configured for a single VC and an
198
+ error code would have been sent to the client to use the batch
199
+ endpoint if there was more than one VC to deliver. */
200
+ const {response, format} = result;
201
+ const {verifiablePresentation: {verifiableCredential: [vc]}} = response;
202
+
203
+ // parse any enveloped VC
204
+ let credential;
205
+ if(vc.type === 'EnvelopedVerifiableCredential' &&
206
+ vc.id?.startsWith('data:application/jwt,')) {
207
+ credential = vc.id.slice('data:application/jwt,'.length);
208
+ } else {
209
+ credential = vc;
210
+ }
211
+
212
+ // send OID4VCI response
213
+ res.json({
214
+ // FIXME: this doesn't seem to be in the spec anymore (draft 14+)...
215
+ format,
216
+ credential
217
+ });
218
+ }));
219
+
220
+ // a batch credential delivery server endpoint
221
+ // receives N credential requests and returns N VCs
222
+ app.options(routes.batchCredential, cors());
223
+ app.post(
224
+ routes.batchCredential,
225
+ cors(),
226
+ validate({bodySchema: openIdBatchCredentialBody}),
227
+ getConfigMiddleware,
228
+ getExchange,
229
+ asyncHandler(async (req, res) => {
230
+ /* Clients must POST, e.g.:
231
+ POST /batch_credential HTTP/1.1
232
+ Host: server.example.com
233
+ Content-Type: application/json
234
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
235
+
236
+ {
237
+ credential_requests: [{
238
+ "format": "ldp_vc",
239
+ "credential_definition": {
240
+ "@context": [
241
+ "https://www.w3.org/2018/credentials/v1",
242
+ "https://www.w3.org/2018/credentials/examples/v1"
243
+ ],
244
+ "type": [
245
+ "VerifiableCredential",
246
+ "UniversityDegreeCredential"
247
+ ]
248
+ },
249
+ "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
250
+ "proof": {
251
+ "proof_type": "jwt",
252
+ "jwt": "eyJra...nOzM"
253
+ }
254
+ }]
255
+ }
256
+ */
257
+ const result = await oid4vci.processCredentialRequests({
258
+ req, res, isBatchRequest: true
259
+ });
260
+ if(!result) {
261
+ // DID proof request response sent
262
+ return;
263
+ }
264
+
265
+ // send VCs
266
+ const {response: {verifiablePresentation}, format} = result;
267
+ // FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)...
268
+ const responses = verifiablePresentation.verifiableCredential.map(vc => {
269
+ // parse any enveloped VC
270
+ let credential;
271
+ if(vc.type === 'EnvelopedVerifiableCredential' &&
272
+ vc.id?.startsWith('data:application/jwt,')) {
273
+ credential = vc.id.slice('data:application/jwt,'.length);
274
+ } else {
275
+ credential = vc;
276
+ }
277
+ return {format, credential};
278
+ });
279
+ res.json({credential_responses: responses});
280
+ }));
281
+
282
+ // an OID4VP verifier endpoint
283
+ // serves the authorization request, including presentation definition
284
+ // associated with the current step in the exchange
285
+ app.get(
286
+ routes.authorizationRequest,
287
+ cors(),
288
+ getConfigMiddleware,
289
+ getExchange,
290
+ asyncHandler(async (req, res) => {
291
+ const {
292
+ authorizationRequest
293
+ } = await oid4vp.getAuthorizationRequest({req});
294
+ // construct and send authz request as unsecured JWT
295
+ const jwt = new UnsecuredJWT(authorizationRequest).encode();
296
+ res.set('content-type', 'application/oauth-authz-req+jwt');
297
+ res.send(jwt);
298
+ }));
299
+
300
+ // an OID4VP verifier endpoint
301
+ // receives an authorization response with vp_token
302
+ app.options(routes.authorizationResponse, cors());
303
+ app.post(
304
+ routes.authorizationResponse,
305
+ cors(),
306
+ urlencoded,
307
+ validate({bodySchema: openIdAuthorizationResponseBody()}),
308
+ getConfigMiddleware,
309
+ getExchange,
310
+ asyncHandler(async (req, res) => {
311
+ const result = await oid4vp.processAuthorizationResponse({req});
312
+ res.json(result);
313
+ }));
314
+ }