@bedrock/vc-delivery 4.8.0 → 5.0.1

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,6 +2,7 @@
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
7
  import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
7
8
  import {httpsAgent} from '@bedrock/https-agent';
@@ -9,7 +10,7 @@ import jsonata from 'jsonata';
9
10
  import {serviceAgents} from '@bedrock/service-agent';
10
11
  import {ZcapClient} from '@digitalbazaar/ezcap';
11
12
 
12
- const {config} = bedrock;
13
+ const {config, util: {BedrockError}} = bedrock;
13
14
 
14
15
  export async function evaluateTemplate({
15
16
  workflow, exchange, typedTemplate
@@ -102,3 +103,67 @@ export function decodeLocalId({localId} = {}) {
102
103
  expectedSize: 16
103
104
  }));
104
105
  }
106
+
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];
129
+ }
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};
143
+ }
144
+
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};
162
+ }
163
+
164
+ throw new BedrockError(
165
+ `Unsupported credential or presentation envelope format "${format}".`, {
166
+ name: 'NotSupportedError',
167
+ details: {httpStatusCode: 400, public: true}
168
+ });
169
+ }
package/lib/openId.js CHANGED
@@ -4,16 +4,19 @@
4
4
  import * as bedrock from '@bedrock/core';
5
5
  import * as exchanges from './exchanges.js';
6
6
  import {
7
- compile, schemas, createValidateMiddleware as validate
7
+ compile, createValidateMiddleware as validate
8
8
  } from '@bedrock/validation';
9
- import {evaluateTemplate, getWorkflowIssuerInstances} from './helpers.js';
9
+ import {
10
+ evaluateTemplate, getWorkflowIssuerInstances, unenvelopePresentation
11
+ } from './helpers.js';
10
12
  import {importJWK, SignJWT} from 'jose';
11
13
  import {
12
14
  openIdAuthorizationResponseBody,
13
15
  openIdBatchCredentialBody,
14
16
  openIdCredentialBody,
15
17
  openIdTokenBody,
16
- presentationSubmission as presentationSubmissionSchema
18
+ presentationSubmission as presentationSubmissionSchema,
19
+ verifiablePresentation as verifiablePresentationSchema
17
20
  } from '../schemas/bedrock-vc-workflow.js';
18
21
  import {verify, verifyDidProofJwt} from './verify.js';
19
22
  import {asyncHandler} from '@bedrock/express';
@@ -56,6 +59,8 @@ instantiating a new authorization server instance per VC exchange. */
56
59
  const PRE_AUTH_GRANT_TYPE =
57
60
  'urn:ietf:params:oauth:grant-type:pre-authorized_code';
58
61
 
62
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
63
+
59
64
  // creates OID4VCI Authorization Server + Credential Delivery Server
60
65
  // endpoints for each individual exchange
61
66
  export async function createRoutes({
@@ -84,7 +89,7 @@ export async function createRoutes({
84
89
 
85
90
  // create validators for x-www-form-urlencoded parsed data
86
91
  const validatePresentation = compile(
87
- {schema: schemas.verifiablePresentation()});
92
+ {schema: verifiablePresentationSchema()});
88
93
  const validatePresentationSubmission = compile(
89
94
  {schema: presentationSubmissionSchema});
90
95
 
@@ -378,14 +383,33 @@ export async function createRoutes({
378
383
  const {vp_token, presentation_submission} = req.body;
379
384
 
380
385
  // JSON parse and validate `vp_token` and `presentation_submission`
381
- const presentation = _jsonParse(vp_token, 'vp_token');
386
+ let presentation = _jsonParse(vp_token, 'vp_token');
382
387
  const presentationSubmission = _jsonParse(
383
388
  presentation_submission, 'presentation_submission');
384
389
  _validate(validatePresentationSubmission, presentationSubmission);
385
- _validate(validatePresentation, presentation);
386
-
387
- const result = await _processAuthorizationResponse(
388
- {req, presentation, presentationSubmission});
390
+ let envelope;
391
+ if(typeof presentation === 'string') {
392
+ // handle enveloped presentation
393
+ const {
394
+ envelope: raw, presentation: contents, format
395
+ } = await unenvelopePresentation({
396
+ envelopedPresentation: presentation,
397
+ // FIXME: check presentationSubmission for VP format
398
+ format: 'jwt_vc_json-ld'
399
+ });
400
+ _validate(validatePresentation, contents);
401
+ presentation = {
402
+ '@context': VC_CONTEXT_2,
403
+ id: `data:${format},${raw}`,
404
+ type: 'EnvelopedVerifiablePresentation'
405
+ };
406
+ envelope = {raw, contents, format};
407
+ } else {
408
+ _validate(validatePresentation, presentation);
409
+ }
410
+ const result = await _processAuthorizationResponse({
411
+ req, presentation, envelope, presentationSubmission
412
+ });
389
413
  res.json(result);
390
414
  }));
391
415
 
@@ -905,7 +929,7 @@ function _matchCredentialRequest(expected, cr) {
905
929
  }
906
930
 
907
931
  async function _processAuthorizationResponse({
908
- req, presentation, presentationSubmission
932
+ req, presentation, envelope, presentationSubmission
909
933
  }) {
910
934
  const {config: workflow} = req.serviceObject;
911
935
  const exchangeRecord = await req.getExchange();
@@ -916,18 +940,17 @@ async function _processAuthorizationResponse({
916
940
  const {authorizationRequest, step} = arRequest;
917
941
  ({exchange} = arRequest);
918
942
 
919
- // FIXME: if the VP is enveloped, remove the envelope to validate or
920
- // run validation code after verification if necessary
921
-
922
943
  // FIXME: check the VP against the presentation submission if requested
923
944
  // FIXME: check the VP against "trustedIssuer" in VPR, if provided
924
945
  const {presentationSchema} = step;
925
946
  if(presentationSchema) {
926
- // validate the received VP
927
- console.log('run presentation schema');
947
+ // if the VP is enveloped, validate the contents of the envelope
948
+ const toValidate = envelope ? envelope.contents : presentation;
949
+
950
+ // validate the received VP / envelope contents
928
951
  const {jsonSchema: schema} = presentationSchema;
929
952
  const validate = compile({schema});
930
- const {valid, error} = validate(presentation);
953
+ const {valid, error} = validate(toValidate);
931
954
  if(!valid) {
932
955
  throw error;
933
956
  }
@@ -937,20 +960,21 @@ async function _processAuthorizationResponse({
937
960
  const {verifiablePresentationRequest} = await oid4vp.toVpr(
938
961
  {authorizationRequest});
939
962
  const {allowUnprotectedPresentation = false} = step;
940
- const {verificationMethod} = await verify({
963
+ const verifyResult = await verify({
941
964
  workflow,
942
965
  verifiablePresentationRequest,
943
966
  presentation,
944
967
  allowUnprotectedPresentation,
945
968
  expectedChallenge: authorizationRequest.nonce
946
969
  });
970
+ const {verificationMethod} = verifyResult;
947
971
 
948
972
  // store VP results in variables associated with current step
949
973
  const currentStep = exchange.step;
950
974
  if(!exchange.variables.results) {
951
975
  exchange.variables.results = {};
952
976
  }
953
- exchange.variables.results[currentStep] = {
977
+ const results = {
954
978
  // common use case of DID Authentication; provide `did` for ease
955
979
  // of use in template
956
980
  did: verificationMethod?.controller || null,
@@ -961,6 +985,13 @@ async function _processAuthorizationResponse({
961
985
  presentationSubmission
962
986
  }
963
987
  };
988
+ if(envelope) {
989
+ // normalize VP from inside envelope to `verifiablePresentation`
990
+ results.envelopedPresentation = presentation;
991
+ results.verifiablePresentation = verifyResult
992
+ .presentationResult.presentation;
993
+ }
994
+ exchange.variables.results[currentStep] = results;
964
995
  exchange.sequence++;
965
996
 
966
997
  // if there is something to issue, update exchange, do not complete it
package/lib/vcapi.js CHANGED
@@ -4,8 +4,8 @@
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
8
  import {compile} from '@bedrock/validation';
8
- import {evaluateTemplate} from './helpers.js';
9
9
  import {issue} from './issue.js';
10
10
  import {klona} from 'klona';
11
11
  import {logger} from './logger.js';
@@ -96,15 +96,22 @@ export async function processExchange({req, res, workflow, exchange}) {
96
96
  return;
97
97
  }
98
98
 
99
- // FIXME: if the VP is enveloped, remove the envelope to validate or
100
- // run validation code after verification if necessary
101
-
102
99
  const {presentationSchema} = step;
103
100
  if(presentationSchema) {
101
+ // if the VP is enveloped, get the presentation from the envelope
102
+ let presentation;
103
+ if(receivedPresentation?.type === 'EnvelopedVerifiablePresentation') {
104
+ ({presentation} = await unenvelopePresentation({
105
+ envelopedPresentation: receivedPresentation
106
+ }));
107
+ } else {
108
+ presentation = receivedPresentation;
109
+ }
110
+
104
111
  // validate the received VP
105
112
  const {jsonSchema: schema} = presentationSchema;
106
113
  const validate = compile({schema});
107
- const {valid, error} = validate(receivedPresentation);
114
+ const {valid, error} = validate(presentation);
108
115
  if(!valid) {
109
116
  throw error;
110
117
  }
package/lib/vcjwt.js ADDED
@@ -0,0 +1,375 @@
1
+ /*!
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import {decodeJwt} from 'jose';
6
+
7
+ const {util: {BedrockError}} = bedrock;
8
+
9
+ const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1';
10
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
11
+
12
+ export function decodeVCJWTCredential({jwt} = {}) {
13
+ const payload = decodeJwt(jwt);
14
+
15
+ /* Example:
16
+ {
17
+ "alg": <signer.algorithm>,
18
+ "kid": <signer.id>
19
+ }.
20
+ {
21
+ "iss": <verifiableCredential.issuer>,
22
+ "jti": <verifiableCredential.id>
23
+ "sub": <verifiableCredential.credentialSubject>
24
+ "nbf": <verifiableCredential.[issuanceDate | validFrom]>
25
+ "exp": <verifiableCredential.[expirationDate | validUntil]>
26
+ "vc": <verifiableCredential>
27
+ }
28
+ */
29
+ const {vc} = payload;
30
+ if(!(vc && typeof vc === 'object')) {
31
+ throw new BedrockError('JWT validation failed.', {
32
+ name: 'DataError',
33
+ details: {
34
+ httpStatusCode: 400,
35
+ public: true,
36
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
37
+ reason: 'missing or unexpected "vc" claim value.',
38
+ claim: 'vc'
39
+ }
40
+ });
41
+ }
42
+
43
+ let {'@context': context = []} = vc;
44
+ if(!Array.isArray(context)) {
45
+ context = [context];
46
+ }
47
+ const isVersion1 = context.includes(VC_CONTEXT_1);
48
+ const isVersion2 = context.includes(VC_CONTEXT_2);
49
+ if(!(isVersion1 ^ isVersion2)) {
50
+ throw new BedrockError(
51
+ 'Verifiable credential is neither version "1.x" nor "2.x".', {
52
+ name: 'DataError',
53
+ details: {
54
+ httpStatusCode: 400,
55
+ public: true
56
+ }
57
+ });
58
+ }
59
+
60
+ const credential = {...vc};
61
+ const {iss, jti, sub, nbf, exp} = payload;
62
+
63
+ // inject `issuer` value
64
+ if(vc.issuer === undefined) {
65
+ vc.issuer = iss;
66
+ } else if(vc.issuer && typeof vc.issuer === 'object' &&
67
+ vc.issuer.id === undefined) {
68
+ vc.issuer = {id: iss, ...vc.issuer};
69
+ } else if(iss !== vc.issuer && iss !== vc.issuer?.id) {
70
+ throw new BedrockError(
71
+ 'VC-JWT "iss" claim does not equal nor does it exclusively ' +
72
+ 'provide verifiable credential "issuer" / "issuer.id".', {
73
+ name: 'DataError',
74
+ details: {
75
+ httpStatusCode: 400,
76
+ public: true
77
+ }
78
+ });
79
+ }
80
+
81
+ if(jti !== undefined && jti !== vc.id) {
82
+ // inject `id` value
83
+ if(vc.id === undefined) {
84
+ vc.id = jti;
85
+ } else {
86
+ throw new BedrockError(
87
+ 'VC-JWT "jti" claim does not equal nor does it exclusively ' +
88
+ 'provide verifiable credential "id".', {
89
+ name: 'DataError',
90
+ details: {
91
+ httpStatusCode: 400,
92
+ public: true
93
+ }
94
+ });
95
+ }
96
+ }
97
+
98
+ if(sub !== undefined && sub !== vc.credentialSubject?.id) {
99
+ // inject `credentialSubject.id` value
100
+ if(!vc.credentialSubject) {
101
+ throw new BedrockError(
102
+ 'Verifiable credential has no "credentialSubject".', {
103
+ name: 'DataError',
104
+ details: {
105
+ httpStatusCode: 400,
106
+ public: true
107
+ }
108
+ });
109
+ }
110
+ if(Array.isArray(vc.credentialSubject)) {
111
+ throw new BedrockError(
112
+ 'Verifiable credential has multiple credential subjects, which is ' +
113
+ 'not supported in VC-JWT.', {
114
+ name: 'DataError',
115
+ details: {
116
+ httpStatusCode: 400,
117
+ public: true
118
+ }
119
+ });
120
+ }
121
+ if(vc.credentialSubject?.id === undefined) {
122
+ vc.credentialSubject = {id: sub, ...vc.credentialSubject};
123
+ } else {
124
+ throw new BedrockError(
125
+ 'VC-JWT "sub" claim does not equal nor does it exclusively ' +
126
+ 'provide verifiable credential "credentialSubject.id".', {
127
+ name: 'DataError',
128
+ details: {
129
+ httpStatusCode: 400,
130
+ public: true
131
+ }
132
+ });
133
+ }
134
+ }
135
+
136
+ if(nbf === undefined && isVersion1) {
137
+ throw new BedrockError('JWT validation failed.', {
138
+ name: 'DataError',
139
+ details: {
140
+ httpStatusCode: 400,
141
+ public: true,
142
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
143
+ reason: 'missing "nbf" claim value.',
144
+ claim: 'nbf'
145
+ }
146
+ });
147
+ }
148
+
149
+ if(nbf !== undefined) {
150
+ // fuzzy convert `nbf` into `issuanceDate` / `validFrom`, only require
151
+ // second-level precision
152
+ const dateString = new Date(nbf * 1000).toISOString().slice(0, -5);
153
+ const dateProperty = isVersion1 ? 'issuanceDate' : 'validFrom';
154
+ // inject dateProperty value
155
+ if(vc[dateProperty] === undefined) {
156
+ vc[dateProperty] = dateString + 'Z';
157
+ } else if(!(vc[dateProperty].startsWith(dateString) &&
158
+ vc[dateProperty].endsWith('Z'))) {
159
+ throw new BedrockError(
160
+ 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' +
161
+ `verifiable credential "${dateProperty}".`, {
162
+ name: 'DataError',
163
+ details: {
164
+ httpStatusCode: 400,
165
+ public: true
166
+ }
167
+ });
168
+ }
169
+ }
170
+
171
+ if(exp !== undefined) {
172
+ // fuzzy convert `exp` into `expirationDate` / `validUntil`, only require
173
+ // second-level precision
174
+ const dateString = new Date(exp * 1000).toISOString().slice(0, -5);
175
+ const dateProperty = isVersion1 ? 'expirationDate' : 'validUntil';
176
+ // inject dateProperty value
177
+ if(vc[dateProperty] === undefined) {
178
+ vc[dateProperty] = dateString + 'Z';
179
+ } else if(!(vc[dateProperty].startsWith(dateString) &&
180
+ vc[dateProperty].endsWith('Z'))) {
181
+ throw new BedrockError(
182
+ 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' +
183
+ `verifiable credential "${dateProperty}".`, {
184
+ name: 'DataError',
185
+ details: {
186
+ httpStatusCode: 400,
187
+ public: true
188
+ }
189
+ });
190
+ }
191
+ }
192
+
193
+ return credential;
194
+ }
195
+
196
+ export function decodeVCJWTPresentation({jwt, challenge} = {}) {
197
+ /* Example:
198
+ {
199
+ "alg": <signer.algorithm>,
200
+ "kid": <signer.id>
201
+ }.
202
+ {
203
+ "iss": <verifiablePresentation.holder>,
204
+ "aud": <verifiablePresentation.domain>,
205
+ "nonce": <verifiablePresentation.nonce>,
206
+ "jti": <verifiablePresentation.id>
207
+ "nbf": <verifiablePresentation.[validFrom]>
208
+ "exp": <verifiablePresentation.[validUntil]>
209
+ "vp": <verifiablePresentation>
210
+ }
211
+ */
212
+ const payload = decodeJwt(jwt);
213
+
214
+ const {vp} = payload;
215
+ if(!(vp && typeof vp === 'object')) {
216
+ throw new BedrockError('JWT validation failed.', {
217
+ name: 'DataError',
218
+ details: {
219
+ httpStatusCode: 400,
220
+ public: true,
221
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
222
+ reason: 'missing or unexpected "vp" claim value.',
223
+ claim: 'vp'
224
+ }
225
+ });
226
+ }
227
+
228
+ let {'@context': context = []} = vp;
229
+ if(!Array.isArray(context)) {
230
+ context = [context];
231
+ }
232
+ const isVersion1 = context.includes(VC_CONTEXT_1);
233
+ const isVersion2 = context.includes(VC_CONTEXT_2);
234
+ if(!(isVersion1 ^ isVersion2)) {
235
+ throw new BedrockError(
236
+ 'Verifiable presentation is not either version "1.x" or "2.x".', {
237
+ name: 'DataError',
238
+ details: {
239
+ httpStatusCode: 400,
240
+ public: true
241
+ }
242
+ });
243
+ }
244
+
245
+ const presentation = {...vp};
246
+ const {iss, nonce, jti, nbf, exp} = payload;
247
+
248
+ // inject `holder` value
249
+ if(vp.holder === undefined) {
250
+ vp.holder = iss;
251
+ } else if(vp.holder && typeof vp.holder === 'object' &&
252
+ vp.holder.id === undefined) {
253
+ vp.holder = {id: iss, ...vp.holder};
254
+ } else if(iss !== vp.holder && iss !== vp.holder?.id) {
255
+ throw new BedrockError(
256
+ 'VC-JWT "iss" claim does not equal nor does it exclusively ' +
257
+ 'provide verifiable presentation "holder" / "holder.id".', {
258
+ name: 'DataError',
259
+ details: {
260
+ httpStatusCode: 400,
261
+ public: true
262
+ }
263
+ });
264
+ }
265
+
266
+ if(jti !== undefined && jti !== vp.id) {
267
+ // inject `id` value
268
+ if(vp.id === undefined) {
269
+ vp.id = jti;
270
+ } else {
271
+ throw new BedrockError(
272
+ 'VC-JWT "jti" claim does not equal nor does it exclusively ' +
273
+ 'provide verifiable presentation "id".', {
274
+ name: 'DataError',
275
+ details: {
276
+ httpStatusCode: 400,
277
+ public: true
278
+ }
279
+ });
280
+ }
281
+ }
282
+
283
+ // version 1.x VPs do not support `validFrom`/`validUntil`
284
+ if(nbf !== undefined && isVersion2) {
285
+ // fuzzy convert `nbf` into `validFrom`, only require
286
+ // second-level precision
287
+ const dateString = new Date(nbf * 1000).toISOString().slice(0, -5);
288
+
289
+ // inject `validFrom` value
290
+ if(vp.validFrom === undefined) {
291
+ vp.validFrom = dateString + 'Z';
292
+ } else if(!(vp.validFrom?.startsWith(dateString) &&
293
+ vp.validFrom.endsWith('Z'))) {
294
+ throw new BedrockError(
295
+ 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' +
296
+ 'verifiable presentation "validFrom".', {
297
+ name: 'DataError',
298
+ details: {
299
+ httpStatusCode: 400,
300
+ public: true
301
+ }
302
+ });
303
+ }
304
+ }
305
+ if(exp !== undefined && isVersion2) {
306
+ // fuzzy convert `exp` into `validUntil`, only require
307
+ // second-level precision
308
+ const dateString = new Date(exp * 1000).toISOString().slice(0, -5);
309
+
310
+ // inject `validUntil` value
311
+ if(vp.validUntil === undefined) {
312
+ vp.validUntil = dateString + 'Z';
313
+ } else if(!(vp.validUntil?.startsWith(dateString) &&
314
+ vp.validUntil?.endsWith('Z'))) {
315
+ throw new BedrockError(
316
+ 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' +
317
+ 'verifiable presentation "validUntil".', {
318
+ name: 'DataError',
319
+ details: {
320
+ httpStatusCode: 400,
321
+ public: true
322
+ }
323
+ });
324
+ }
325
+ }
326
+
327
+ if(challenge !== undefined && nonce !== challenge) {
328
+ throw new BedrockError('JWT validation failed.', {
329
+ name: 'DataError',
330
+ details: {
331
+ httpStatusCode: 400,
332
+ public: true,
333
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
334
+ reason: 'missing or unexpected "nonce" claim value.',
335
+ claim: 'nonce'
336
+ }
337
+ });
338
+ }
339
+
340
+ // do some validation on `verifiableCredential`
341
+ let {verifiableCredential = []} = presentation;
342
+ if(!Array.isArray(verifiableCredential)) {
343
+ verifiableCredential = [verifiableCredential];
344
+ }
345
+
346
+ // ensure version 2 VPs only have objects in `verifiableCredential`
347
+ const hasVCJWTs = verifiableCredential.some(vc => typeof vc !== 'object');
348
+ if(isVersion2 && hasVCJWTs) {
349
+ throw new BedrockError(
350
+ 'Version 2.x verifiable presentations must only use objects in the ' +
351
+ '"verifiableCredential" field.', {
352
+ name: 'DataError',
353
+ details: {
354
+ httpStatusCode: 400,
355
+ public: true
356
+ }
357
+ });
358
+ }
359
+
360
+ // transform any VC-JWT VCs to enveloped VCs
361
+ if(presentation.verifiableCredential && hasVCJWTs) {
362
+ presentation.verifiableCredential = verifiableCredential.map(vc => {
363
+ if(typeof vc !== 'string') {
364
+ return vc;
365
+ }
366
+ return {
367
+ '@context': VC_CONTEXT_2,
368
+ id: `data:application/jwt,${vc}`,
369
+ type: 'EnvelopedVerifiableCredential',
370
+ };
371
+ });
372
+ }
373
+
374
+ return presentation;
375
+ }
package/lib/verify.js CHANGED
@@ -2,6 +2,7 @@
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 EcdsaMultikey from '@digitalbazaar/ecdsa-multikey';
5
6
  import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey';
6
7
  import {importJWK, jwtVerify} from 'jose';
7
8
  import {didIo} from '@bedrock/did-io';
@@ -9,6 +10,10 @@ import {getZcapClient} from './helpers.js';
9
10
 
10
11
  const {util: {BedrockError}} = bedrock;
11
12
 
13
+ // supported JWT algs
14
+ const ECDSA_ALGS = ['ES256', 'ES384'];
15
+ const EDDSA_ALGS = ['Ed25519', 'EdDSA'];
16
+
12
17
  export async function createChallenge({workflow} = {}) {
13
18
  // create zcap client for creating challenges
14
19
  const {zcapClient, zcaps} = await getZcapClient({workflow});
@@ -82,9 +87,13 @@ export async function verify({
82
87
 
83
88
  // generate useful error to return to client
84
89
  const {name, errors, message} = cause.data.error;
90
+ const causeError = _stripStacktrace({...cause.data.error});
91
+ delete causeError.errors;
85
92
  const error = new BedrockError(message ?? 'Verification error.', {
86
- name: name === 'VerificationError' ? 'DataError' : 'OperationError',
93
+ name: (name === 'VerificationError' || name === 'DataError') ?
94
+ 'DataError' : 'OperationError',
87
95
  details: {
96
+ error: causeError,
88
97
  verified,
89
98
  credentialResults,
90
99
  presentationResult,
@@ -134,23 +143,50 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
134
143
  const audience = exchangeId;
135
144
 
136
145
  let issuer;
137
- const resolveKey = async protectedHeader => {
138
- const vm = await didIo.get({url: protectedHeader.kid});
146
+ // `resolveKey` is passed `protectedHeader`
147
+ const resolveKey = async ({alg, kid}) => {
148
+ const isEcdsa = ECDSA_ALGS.includes(alg);
149
+ const isEddsa = !isEcdsa && EDDSA_ALGS.includes(alg);
150
+ if(!(isEcdsa || isEddsa)) {
151
+ throw new BedrockError(
152
+ `Unsupported JWT "alg": "${alg}".`, {
153
+ name: 'DataError',
154
+ details: {
155
+ httpStatusCode: 400,
156
+ public: true
157
+ }
158
+ });
159
+ }
160
+
161
+ const vm = await didIo.get({url: kid});
139
162
  // `vm.controller` must be the issuer of the DID JWT; also ensure that
140
163
  // the specified controller authorized `vm` for the purpose of
141
164
  // authentication
142
165
  issuer = vm.controller;
143
166
  const didDoc = await didIo.get({url: issuer});
144
- if(!(didDoc?.authentication?.some(e => e === vm.id || e.id === vm.id))) {
167
+ let match = didDoc?.authentication?.find?.(
168
+ e => e === vm.id || e.id === vm.id);
169
+ if(typeof match === 'string') {
170
+ match = didDoc?.verificationMethod?.find?.(e => e.id === vm.id);
171
+ }
172
+ if(!(match && Array.isArray(match.controller) ?
173
+ match.controller.includes(vm.controller) :
174
+ match.controller === vm.controller)) {
145
175
  throw new BedrockError(
146
176
  `Verification method controller "${issuer}" did not authorize ` +
147
177
  `verification method "${vm.id}" for the purpose of "authentication".`,
148
178
  {name: 'NotAllowedError'});
149
179
  }
150
- // FIXME: support other key types
151
- const keyPair = await Ed25519Multikey.from(vm);
152
- const jwk = await Ed25519Multikey.toJwk({keyPair});
153
- jwk.alg = 'EdDSA';
180
+ let jwk;
181
+ if(isEcdsa) {
182
+ const keyPair = await EcdsaMultikey.from(vm);
183
+ jwk = await EcdsaMultikey.toJwk({keyPair});
184
+ jwk.alg = alg;
185
+ } else {
186
+ const keyPair = await Ed25519Multikey.from(vm);
187
+ jwk = await Ed25519Multikey.toJwk({keyPair});
188
+ jwk.alg = 'EdDSA';
189
+ }
154
190
  return importJWK(jwk);
155
191
  };
156
192
 
@@ -186,7 +222,7 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
186
222
  }
187
223
 
188
224
  // check `iss` claim
189
- if(!(verifyResult?.payload?.iss === issuer)) {
225
+ if(!(issuer && verifyResult?.payload?.iss === issuer)) {
190
226
  throw new BedrockError('DID proof JWT validation failed.', {
191
227
  name: 'NotAllowedError',
192
228
  details: {
@@ -200,7 +236,7 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
200
236
  }
201
237
 
202
238
  // check `nonce` claim
203
- if(!(verifyResult?.payload?.nonce === exchange.id)) {
239
+ if(verifyResult?.payload?.nonce !== exchange.id) {
204
240
  throw new BedrockError('DID proof JWT validation failed.', {
205
241
  name: 'NotAllowedError',
206
242
  details: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrock/vc-delivery",
3
- "version": "4.8.0",
3
+ "version": "5.0.1",
4
4
  "type": "module",
5
5
  "description": "Bedrock Verifiable Credential Delivery",
6
6
  "main": "./lib/index.js",
@@ -9,7 +9,7 @@
9
9
  "schemas/**/*.js"
10
10
  ],
11
11
  "scripts": {
12
- "lint": "eslint ."
12
+ "lint": "eslint --ext .cjs,.js ."
13
13
  },
14
14
  "repository": {
15
15
  "type": "git",
@@ -35,41 +35,40 @@
35
35
  },
36
36
  "homepage": "https://github.com/digitalbazaar/bedrock-vc-delivery",
37
37
  "dependencies": {
38
+ "@digitalbazaar/ecdsa-multikey": "^1.7.0",
38
39
  "@digitalbazaar/ed25519-multikey": "^1.1.0",
39
- "@digitalbazaar/ed25519-signature-2020": "^5.2.0",
40
- "@digitalbazaar/ezcap": "^4.0.0",
41
- "@digitalbazaar/oid4-client": "^3.1.0",
42
- "@digitalbazaar/vc": "^6.0.1",
40
+ "@digitalbazaar/ed25519-signature-2020": "^5.4.0",
41
+ "@digitalbazaar/ezcap": "^4.1.0",
42
+ "@digitalbazaar/oid4-client": "^3.4.1",
43
+ "@digitalbazaar/vc": "^7.0.0",
43
44
  "assert-plus": "^1.0.0",
44
45
  "bnid": "^3.0.0",
45
- "body-parser": "^1.20.1",
46
+ "body-parser": "^1.20.2",
46
47
  "cors": "^2.8.5",
47
- "jose": "^4.10.4",
48
- "jsonata": "^2.0.3",
49
- "klona": "^2.0.5"
48
+ "jose": "^5.6.3",
49
+ "jsonata": "^2.0.5",
50
+ "klona": "^2.0.6"
50
51
  },
51
52
  "peerDependencies": {
52
53
  "@bedrock/app-identity": "4.0.0",
53
- "@bedrock/core": "^6.0.1",
54
- "@bedrock/did-io": "^10.0.0",
55
- "@bedrock/express": "^8.0.0",
56
- "@bedrock/https-agent": "^4.0.0",
57
- "@bedrock/mongodb": "^10.0.0",
58
- "@bedrock/oauth2-verifier": "^2.0.0",
59
- "@bedrock/service-agent": "^8.0.0",
60
- "@bedrock/service-core": "^9.0.0",
54
+ "@bedrock/core": "^6.1.3",
55
+ "@bedrock/did-io": "^10.3.1",
56
+ "@bedrock/express": "^8.3.1",
57
+ "@bedrock/https-agent": "^4.1.0",
58
+ "@bedrock/mongodb": "^10.2.0",
59
+ "@bedrock/oauth2-verifier": "^2.1.0",
60
+ "@bedrock/service-agent": "^9.0.2",
61
+ "@bedrock/service-core": "^10.0.0",
61
62
  "@bedrock/validation": "^7.1.0"
62
63
  },
63
64
  "directories": {
64
65
  "lib": "./lib"
65
66
  },
66
67
  "devDependencies": {
67
- "eslint": "^8.41.0",
68
- "eslint-config-digitalbazaar": "^5.0.1",
69
- "eslint-plugin-jsdoc": "^46.3.0",
70
- "eslint-plugin-unicorn": "^47.0.0",
71
- "jsdoc": "^4.0.2",
72
- "jsdoc-to-markdown": "^8.0.0"
68
+ "eslint": "^8.57.0",
69
+ "eslint-config-digitalbazaar": "^5.2.0",
70
+ "eslint-plugin-jsdoc": "^48.11.0",
71
+ "eslint-plugin-unicorn": "^55.0.0"
73
72
  },
74
73
  "engines": {
75
74
  "node": ">=18"
@@ -4,6 +4,144 @@
4
4
  import {MAX_ISSUER_INSTANCES} from '../lib/constants.js';
5
5
  import {schemas} from '@bedrock/validation';
6
6
 
7
+ const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1';
8
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
9
+
10
+ const vcContext = {
11
+ type: 'array',
12
+ minItems: 1,
13
+ // the first context must be the VC context
14
+ items: [{
15
+ oneOf: [{
16
+ const: VC_CONTEXT_1
17
+ }, {
18
+ const: VC_CONTEXT_2
19
+ }]
20
+ }],
21
+ // additional contexts maybe strings or objects
22
+ additionalItems: {
23
+ anyOf: [{type: 'string'}, {type: 'object'}]
24
+ }
25
+ };
26
+
27
+ function idOrObjectWithId() {
28
+ return {
29
+ title: 'identifier or an object with an id',
30
+ anyOf: [
31
+ schemas.identifier(),
32
+ {
33
+ type: 'object',
34
+ required: ['id'],
35
+ additionalProperties: true,
36
+ properties: {id: schemas.identifier()}
37
+ }
38
+ ]
39
+ };
40
+ }
41
+
42
+ function verifiableCredential() {
43
+ return {
44
+ title: 'Verifiable Credential',
45
+ type: 'object',
46
+ required: [
47
+ '@context',
48
+ 'credentialSubject',
49
+ 'issuer',
50
+ 'type'
51
+ ],
52
+ additionalProperties: true,
53
+ properties: {
54
+ '@context': vcContext,
55
+ credentialSubject: {
56
+ anyOf: [
57
+ {type: 'object'},
58
+ {type: 'array', minItems: 1, items: {type: 'object'}}
59
+ ]
60
+ },
61
+ id: {
62
+ type: 'string'
63
+ },
64
+ issuer: idOrObjectWithId(),
65
+ type: {
66
+ type: 'array',
67
+ minItems: 1,
68
+ // this first type must be VerifiableCredential
69
+ items: [
70
+ {const: 'VerifiableCredential'},
71
+ ],
72
+ // additional types must be strings
73
+ additionalItems: {
74
+ type: 'string'
75
+ }
76
+ },
77
+ proof: schemas.proof()
78
+ }
79
+ };
80
+ }
81
+
82
+ const envelopedVerifiableCredential = {
83
+ title: 'Enveloped Verifiable Credential',
84
+ type: 'object',
85
+ additionalProperties: true,
86
+ properties: {
87
+ '@context': {
88
+ const: VC_CONTEXT_2
89
+ },
90
+ id: {
91
+ type: 'string'
92
+ },
93
+ type: {
94
+ const: 'EnvelopedVerifiableCredential'
95
+ }
96
+ },
97
+ required: [
98
+ '@context',
99
+ 'id',
100
+ 'type'
101
+ ]
102
+ };
103
+
104
+ export function verifiablePresentation() {
105
+ return {
106
+ title: 'Verifiable Presentation',
107
+ type: 'object',
108
+ required: ['@context', 'type'],
109
+ additionalProperties: true,
110
+ properties: {
111
+ '@context': vcContext,
112
+ id: {
113
+ type: 'string'
114
+ },
115
+ type: {
116
+ type: 'array',
117
+ minItems: 1,
118
+ // this first type must be VerifiablePresentation
119
+ items: [
120
+ {const: 'VerifiablePresentation'},
121
+ ],
122
+ // additional types must be strings
123
+ additionalItems: {
124
+ type: 'string'
125
+ }
126
+ },
127
+ verifiableCredential: {
128
+ anyOf: [
129
+ verifiableCredential(),
130
+ envelopedVerifiableCredential, {
131
+ type: 'array',
132
+ minItems: 1,
133
+ items: {
134
+ anyOf: [verifiableCredential(), envelopedVerifiableCredential]
135
+ }
136
+ }
137
+ ]
138
+ },
139
+ holder: idOrObjectWithId(),
140
+ proof: schemas.proof()
141
+ }
142
+ };
143
+ }
144
+
7
145
  const credentialDefinition = {
8
146
  title: 'OID4VCI Verifiable Credential Definition',
9
147
  type: 'object',
@@ -19,7 +157,7 @@ const credentialDefinition = {
19
157
  },
20
158
  type: {
21
159
  type: 'array',
22
- minItems: 2,
160
+ minItems: 1,
23
161
  item: {
24
162
  type: 'string'
25
163
  }
@@ -27,7 +165,7 @@ const credentialDefinition = {
27
165
  // allow `types` to be flexible for OID4VCI draft 20 implementers
28
166
  types: {
29
167
  type: 'array',
30
- minItems: 2,
168
+ minItems: 1,
31
169
  item: {
32
170
  type: 'string'
33
171
  }
@@ -165,7 +303,7 @@ const vcFormats = {
165
303
  const issuerInstance = {
166
304
  title: 'Issuer Instance',
167
305
  type: 'object',
168
- required: ['zcapReferenceIds'],
306
+ required: ['supportedFormats', 'zcapReferenceIds'],
169
307
  additionalProperties: false,
170
308
  properties: {
171
309
  id: {
@@ -327,7 +465,7 @@ export function useExchangeBody() {
327
465
  type: 'object',
328
466
  additionalProperties: false,
329
467
  properties: {
330
- verifiablePresentation: schemas.verifiablePresentation()
468
+ verifiablePresentation: verifiablePresentation()
331
469
  }
332
470
  };
333
471
  }