@bedrock/vc-verifier 20.0.0 → 20.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/di.js ADDED
@@ -0,0 +1,63 @@
1
+ /*!
2
+ * Copyright (c) 2018-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as vc from '@digitalbazaar/vc';
5
+ import {checkStatus} from './status.js';
6
+ import {createDocumentLoader} from './documentLoader.js';
7
+ import {createSuites} from './suites.js';
8
+
9
+ export async function verifyCredential({config, credential, checks} = {}) {
10
+ const documentLoader = await createDocumentLoader({config});
11
+ const suite = createSuites();
12
+
13
+ const result = await vc.verifyCredential({
14
+ credential,
15
+ documentLoader,
16
+ suite,
17
+ // only check credential status when option is set
18
+ checkStatus: checks.includes('credentialStatus') ?
19
+ checkStatus : () => ({verified: true})
20
+ });
21
+ // if proof should have been checked but wasn't due to an error,
22
+ // try to run the check again using the VC's issuance date
23
+ if(checks.includes('proof') &&
24
+ result.error && !result.proof && result.results?.[0] &&
25
+ typeof credential.issuanceDate === 'string') {
26
+ const proofResult = await vc.verifyCredential({
27
+ credential,
28
+ documentLoader,
29
+ suite,
30
+ now: new Date(credential.issuanceDate),
31
+ // only check credential status when option is set
32
+ checkStatus: checks.includes('credentialStatus') ?
33
+ checkStatus : () => ({verified: true})
34
+ });
35
+ if(proofResult.verified) {
36
+ // overlay original (failed) results on top of proof results
37
+ result.results[0] = {
38
+ ...proofResult.results[0],
39
+ ...result.results[0],
40
+ proofVerified: true
41
+ };
42
+ }
43
+ }
44
+ // ensure all proofs are verified in order to return `verified`
45
+ let {verified} = result;
46
+ verified = !!(verified && result?.results?.every(({verified}) => verified));
47
+ return {...result, verified};
48
+ }
49
+
50
+ export async function verifyPresentation({
51
+ config, presentation, challenge, domain, checks
52
+ } = {}) {
53
+ const verifyOptions = {
54
+ challenge,
55
+ domain,
56
+ presentation,
57
+ documentLoader: await createDocumentLoader({config}),
58
+ suite: createSuites(),
59
+ unsignedPresentation: !checks.includes('proof'),
60
+ checkStatus
61
+ };
62
+ return vc.verify(verifyOptions);
63
+ }
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import * as vcjwt from './vcjwt.js';
6
+
7
+ const {util: {BedrockError}} = bedrock;
8
+
9
+ export async function verifyEnvelopedCredential({envelopedCredential} = {}) {
10
+ try {
11
+ const {contents: jwt} = _parseEnvelope({
12
+ envelope: envelopedCredential
13
+ });
14
+ return vcjwt.verifyEnvelopedCredential({jwt});
15
+ } catch(error) {
16
+ return {verified: false, error};
17
+ }
18
+ }
19
+
20
+ export async function verifyEnvelopedPresentation({
21
+ envelopedPresentation, challenge, domain
22
+ } = {}) {
23
+ try {
24
+ const {contents: jwt} = _parseEnvelope({
25
+ envelope: envelopedPresentation
26
+ });
27
+ return vcjwt.verifyEnvelopedPresentation({jwt, challenge, domain});
28
+ } catch(error) {
29
+ return {verified: false, error};
30
+ }
31
+ }
32
+
33
+ function _parseEnvelope({envelope}) {
34
+ const {id} = envelope;
35
+ let format;
36
+ const comma = id.indexOf(',');
37
+ if(id.startsWith('data:') && comma !== -1) {
38
+ format = id.slice('data:'.length, comma);
39
+ }
40
+ if(format !== 'application/jwt') {
41
+ throw new BedrockError(
42
+ `Unknown envelope format "${format}".`, {
43
+ name: 'DataError',
44
+ details: {
45
+ httpStatusCode: 400,
46
+ public: true
47
+ },
48
+ });
49
+ }
50
+ return {contents: id.slice(comma + 1), format};
51
+ }
package/lib/http.js CHANGED
@@ -1,8 +1,7 @@
1
1
  /*!
2
- * Copyright (c) 2018-2022 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2018-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
4
  import * as bedrock from '@bedrock/core';
5
- import * as vc from '@digitalbazaar/vc';
6
5
  import {createChallenge, verifyChallenge} from './challenges.js';
7
6
  import {
8
7
  createChallengeBody,
@@ -10,12 +9,10 @@ import {
10
9
  verifyPresentationBody
11
10
  } from '../schemas/bedrock-vc-verifier.js';
12
11
  import {metering, middleware} from '@bedrock/service-core';
12
+ import {verifyCredential, verifyPresentation} from './verify.js';
13
13
  import {asyncHandler} from '@bedrock/express';
14
14
  import bodyParser from 'body-parser';
15
- import {checkStatus} from './status.js';
16
15
  import cors from 'cors';
17
- import {createDocumentLoader} from './documentLoader.js';
18
- import {createSuites} from './suites.js';
19
16
  import {serializeError} from 'serialize-error';
20
17
  import {createValidateMiddleware as validate} from '@bedrock/validation';
21
18
 
@@ -33,7 +30,6 @@ bedrock.events.on('bedrock-express.configure.bodyParser', app => {
33
30
 
34
31
  export async function addRoutes({app, service} = {}) {
35
32
  const {routePrefix} = service;
36
- const suite = createSuites();
37
33
  const cfg = bedrock.config['vc-verifier'];
38
34
  const baseUrl = `${routePrefix}/:localId`;
39
35
  const routes = {
@@ -70,12 +66,11 @@ export async function addRoutes({app, service} = {}) {
70
66
  app.post(
71
67
  routes.credentialsVerify,
72
68
  cors(),
73
- validate({bodySchema: verifyCredentialBody}),
69
+ validate({bodySchema: verifyCredentialBody()}),
74
70
  getConfigMiddleware,
75
71
  middleware.authorizeServiceObjectRequest(),
76
72
  asyncHandler(async (req, res) => {
77
73
  const {config} = req.serviceObject;
78
- const documentLoader = await createDocumentLoader({config});
79
74
 
80
75
  let response;
81
76
  try {
@@ -86,37 +81,7 @@ export async function addRoutes({app, service} = {}) {
86
81
 
87
82
  const {checks} = options;
88
83
  _validateChecks({checks});
89
- const result = await vc.verifyCredential({
90
- credential,
91
- documentLoader,
92
- suite,
93
- // only check credential status when option is set
94
- checkStatus: checks.includes('credentialStatus') ?
95
- checkStatus : () => ({verified: true})
96
- });
97
- // if proof should have been checked but wasn't due to an error,
98
- // try to run the check again using the VC's issuance date
99
- if(checks.includes('proof') &&
100
- result.error && !result.proof && result.results[0] &&
101
- typeof credential.issuanceDate === 'string') {
102
- const proofResult = await vc.verifyCredential({
103
- credential,
104
- documentLoader,
105
- suite,
106
- now: new Date(credential.issuanceDate),
107
- // only check credential status when option is set
108
- checkStatus: checks.includes('credentialStatus') ?
109
- checkStatus : () => ({verified: true})
110
- });
111
- if(proofResult.verified) {
112
- // overlay original (failed) results on top of proof results
113
- result.results[0] = {
114
- ...proofResult.results[0],
115
- ...result.results[0],
116
- proofVerified: true
117
- };
118
- }
119
- }
84
+ const result = await verifyCredential({config, credential, checks});
120
85
  response = _createResponse({credential, result, checks});
121
86
  } catch(e) {
122
87
  response = _createResponse({error: e});
@@ -150,17 +115,16 @@ export async function addRoutes({app, service} = {}) {
150
115
  app.post(
151
116
  routes.presentationsVerify,
152
117
  cors(),
153
- validate({bodySchema: verifyPresentationBody}),
118
+ validate({bodySchema: verifyPresentationBody()}),
154
119
  getConfigMiddleware,
155
120
  middleware.authorizeServiceObjectRequest(),
156
121
  asyncHandler(async (req, res) => {
157
122
  const {config} = req.serviceObject;
158
- const documentLoader = await createDocumentLoader({config});
159
123
 
160
124
  let response;
161
125
  try {
162
126
  const {
163
- verifiablePresentation,
127
+ verifiablePresentation: presentation,
164
128
  options = {}
165
129
  } = req.body;
166
130
 
@@ -174,7 +138,6 @@ export async function addRoutes({app, service} = {}) {
174
138
  }
175
139
 
176
140
  _validateChecks({checks});
177
- const unsignedPresentation = !checks.includes('proof');
178
141
 
179
142
  // allow for `checks` to indicate whether or not the challenge
180
143
  // should be checked
@@ -190,20 +153,12 @@ export async function addRoutes({app, service} = {}) {
190
153
  ({uses: challengeUses} = result);
191
154
  }
192
155
 
193
- const verifyOptions = {
194
- challenge,
195
- presentation: verifiablePresentation,
196
- documentLoader,
197
- suite,
198
- unsignedPresentation,
199
- checkStatus
200
- };
201
- const {proof} = verifiablePresentation;
202
- if(proof && proof.domain) {
203
- // FIXME: do not set a default
204
- verifyOptions.domain = domain || 'issuer.example.com';
205
- }
206
- const result = await vc.verify(verifyOptions);
156
+ // FIXME: do not set a default domain
157
+ const expectedDomain = domain ??
158
+ (presentation?.proof?.domain && 'issuer.example.com');
159
+ const result = await verifyPresentation({
160
+ config, presentation, challenge, domain: expectedDomain, checks
161
+ });
207
162
  response = _createResponse({result, challengeUses, checks});
208
163
  } catch(e) {
209
164
  response = _createResponse({error: e});
package/lib/vcjwt.js ADDED
@@ -0,0 +1,537 @@
1
+ /*
2
+ * Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey';
6
+ import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey';
7
+ import {importJWK, jwtVerify} from 'jose';
8
+ import {didIo} from '@bedrock/did-io';
9
+
10
+ const {util: {BedrockError}} = bedrock;
11
+
12
+ // supported JWT algs
13
+ const ECDSA_ALGS = ['ES256', 'ES384'];
14
+ const EDDSA_ALGS = ['Ed25519', 'EdDSA'];
15
+
16
+ const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1';
17
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
18
+
19
+ export async function verifyEnvelopedCredential({jwt} = {}) {
20
+ try {
21
+ const {
22
+ verified, controller, verificationMethod, verifyResult
23
+ } = await _verifyJwt({jwt, proofPurpose: 'assertionMethod'});
24
+ // if verified, parse credential from payload...
25
+ let credential;
26
+ if(verified) {
27
+ credential = _jwtPayloadToCredential({verifyResult});
28
+ }
29
+ const results = [{
30
+ verified,
31
+ verificationMethod,
32
+ controller,
33
+ verifyResult,
34
+ credential
35
+ }];
36
+ return {verified, controller, results, credential};
37
+ } catch(error) {
38
+ return {verified: false, error};
39
+ }
40
+ }
41
+
42
+ export async function verifyEnvelopedPresentation({
43
+ jwt, challenge, domain
44
+ } = {}) {
45
+ try {
46
+ const {
47
+ verified, controller, verificationMethod, verifyResult
48
+ } = await _verifyJwt({
49
+ jwt, proofPurpose: 'authentication', audience: domain
50
+ });
51
+ // if verified, parse presentation from payload...
52
+ let presentation;
53
+ if(verified) {
54
+ presentation = _jwtPayloadToPresentation({
55
+ verifyResult, challenge
56
+ });
57
+ }
58
+ const results = [{
59
+ verified,
60
+ verificationMethod,
61
+ controller,
62
+ verifyResult,
63
+ presentation
64
+ }];
65
+ return {verified, controller, results, presentation};
66
+ } catch(error) {
67
+ return {verified: false, error};
68
+ }
69
+ }
70
+
71
+ async function _verifyJwt({jwt, proofPurpose, audience} = {}) {
72
+ let verificationMethod;
73
+ let controller;
74
+ // `resolveKey` is passed `protectedHeader`
75
+ const resolveKey = async ({alg, kid}) => {
76
+ const isEcdsa = ECDSA_ALGS.includes(alg);
77
+ const isEddsa = !isEcdsa && EDDSA_ALGS.includes(alg);
78
+ if(!(isEcdsa || isEddsa)) {
79
+ throw new BedrockError(
80
+ `Unsupported JWT "alg": "${alg}".`, {
81
+ name: 'DataError',
82
+ details: {
83
+ httpStatusCode: 400,
84
+ public: true
85
+ }
86
+ });
87
+ }
88
+
89
+ const vm = await didIo.get({url: kid});
90
+ // `vm.controller` must be the issuer of the JWT; also ensure that
91
+ // the specified controller authorized `vm` for the given proof purpose
92
+ ({controller} = vm);
93
+ verificationMethod = vm;
94
+ const didDoc = await didIo.get({url: controller});
95
+ let match = didDoc?.authentication?.find?.(
96
+ e => e === vm.id || e.id === vm.id);
97
+ if(typeof match === 'string') {
98
+ match = didDoc?.verificationMethod?.find?.(e => e.id === vm.id);
99
+ }
100
+ if(!(match && Array.isArray(match.controller) ?
101
+ match.controller.includes(vm.controller) :
102
+ match.controller === vm.controller)) {
103
+ throw new BedrockError(
104
+ `Verification method controller "${controller}" did not authorize ` +
105
+ `verification method "${vm.id}" for the purpose ` +
106
+ `of "${proofPurpose}".`, {
107
+ name: 'DataError',
108
+ details: {
109
+ httpStatusCode: 400,
110
+ public: true
111
+ }
112
+ });
113
+ }
114
+ let jwk;
115
+ if(isEcdsa) {
116
+ const keyPair = await EcdsaMultikey.from(vm);
117
+ jwk = await EcdsaMultikey.toJwk({keyPair});
118
+ jwk.alg = alg;
119
+ } else {
120
+ const keyPair = await Ed25519Multikey.from(vm);
121
+ jwk = await Ed25519Multikey.toJwk({keyPair});
122
+ jwk.alg = 'EdDSA';
123
+ }
124
+ return importJWK(jwk);
125
+ };
126
+
127
+ // FIXME: enable allowed algorithms to be configurable per instance
128
+ const allowedAlgorithms = ['EdDSA', 'Ed25519', 'ES256', 'ES256K', 'ES384'];
129
+ // FIXME: enable `maxClockSkew` to be configurable per instance
130
+ // default is 300 secs
131
+ const maxClockSkew = 300;
132
+
133
+ // use `jose` lib (for now) to verify JWT and return `payload`;
134
+ // pass optional supported algorithms as allow list ... note
135
+ // that `jose` *always* prohibits the `none` algorithm
136
+ let verifyResult;
137
+ try {
138
+ // `jwtVerify` checks claims: `aud`, `exp`, `nbf`
139
+ const {payload, protectedHeader} = await jwtVerify(jwt, resolveKey, {
140
+ algorithms: allowedAlgorithms,
141
+ clockTolerance: maxClockSkew,
142
+ audience
143
+ });
144
+ verifyResult = {payload, protectedHeader};
145
+ } catch(e) {
146
+ const details = {
147
+ httpStatusCode: 403,
148
+ public: true,
149
+ code: e.code,
150
+ reason: e.message
151
+ };
152
+ if(e.claim) {
153
+ details.claim = e.claim;
154
+ }
155
+ throw new BedrockError('JWT validation failed.', {
156
+ name: 'DataError',
157
+ details
158
+ });
159
+ }
160
+
161
+ // check `iss` claim
162
+ if(!(controller && verifyResult?.payload?.iss === controller)) {
163
+ throw new BedrockError('JWT validation failed.', {
164
+ name: 'DataError',
165
+ details: {
166
+ httpStatusCode: 400,
167
+ public: true,
168
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
169
+ reason: 'unexpected "iss" claim value.',
170
+ claim: 'iss'
171
+ }
172
+ });
173
+ }
174
+
175
+ return {verified: true, verificationMethod, controller, verifyResult};
176
+ }
177
+
178
+ function _jwtPayloadToCredential({verifyResult} = {}) {
179
+ /* Example:
180
+ {
181
+ "alg": <signer.algorithm>,
182
+ "kid": <signer.id>
183
+ }.
184
+ {
185
+ "iss": <verifiableCredential.issuer>,
186
+ "jti": <verifiableCredential.id>
187
+ "sub": <verifiableCredential.credentialSubject>
188
+ "nbf": <verifiableCredential.[issuanceDate | validFrom]>
189
+ "exp": <verifiableCredential.[expirationDate | validUntil]>
190
+ "vc": <verifiableCredential>
191
+ }
192
+ */
193
+ const {vc} = verifyResult.payload;
194
+ if(!(vc && typeof vc === 'object')) {
195
+ throw new BedrockError('JWT validation failed.', {
196
+ name: 'DataError',
197
+ details: {
198
+ httpStatusCode: 400,
199
+ public: true,
200
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
201
+ reason: 'missing or unexpected "vc" claim value.',
202
+ claim: 'vc'
203
+ }
204
+ });
205
+ }
206
+
207
+ let {'@context': context = []} = vc;
208
+ if(!Array.isArray(context)) {
209
+ context = [context];
210
+ }
211
+ const isVersion1 = context.includes(VC_CONTEXT_1);
212
+ const isVersion2 = context.includes(VC_CONTEXT_2);
213
+ if(!(isVersion1 ^ isVersion2)) {
214
+ throw new BedrockError(
215
+ 'Verifiable credential is neither version "1.x" nor "2.x".', {
216
+ name: 'DataError',
217
+ details: {
218
+ httpStatusCode: 400,
219
+ public: true
220
+ }
221
+ });
222
+ }
223
+
224
+ const credential = {...vc};
225
+ const {iss, jti, sub, nbf, exp} = verifyResult.payload;
226
+
227
+ // inject `issuer` value
228
+ if(vc.issuer === undefined) {
229
+ vc.issuer = iss;
230
+ } else if(vc.issuer && typeof vc.issuer === 'object' &&
231
+ vc.issuer.id === undefined) {
232
+ vc.issuer.id = iss;
233
+ } else if(iss !== vc.issuer && iss !== vc.issuer?.id) {
234
+ throw new BedrockError(
235
+ 'VC-JWT "iss" claim does not equal nor does it exclusively ' +
236
+ 'provide verifiable credential "issuer" / "issuer.id".', {
237
+ name: 'DataError',
238
+ details: {
239
+ httpStatusCode: 400,
240
+ public: true
241
+ }
242
+ });
243
+ }
244
+
245
+ if(jti !== undefined && jti !== vc.id) {
246
+ // inject `id` value
247
+ if(vc.id === undefined) {
248
+ vc.id = jti;
249
+ } else {
250
+ throw new BedrockError(
251
+ 'VC-JWT "jti" claim does not equal nor does it exclusively ' +
252
+ 'provide verifiable credential "id".', {
253
+ name: 'DataError',
254
+ details: {
255
+ httpStatusCode: 400,
256
+ public: true
257
+ }
258
+ });
259
+ }
260
+ }
261
+
262
+ if(sub !== undefined && sub !== vc.credentialSubject?.id) {
263
+ // inject `credentialSubject.id` value
264
+ if(!vc.credentialSubject) {
265
+ throw new BedrockError(
266
+ 'Verifiable credential has no "credentialSubject".', {
267
+ name: 'DataError',
268
+ details: {
269
+ httpStatusCode: 400,
270
+ public: true
271
+ }
272
+ });
273
+ }
274
+ if(Array.isArray(vc.credentialSubject)) {
275
+ throw new BedrockError(
276
+ 'Verifiable credential has multiple credential subjects, which is ' +
277
+ 'not supported in VC-JWT.', {
278
+ name: 'DataError',
279
+ details: {
280
+ httpStatusCode: 400,
281
+ public: true
282
+ }
283
+ });
284
+ }
285
+ if(vc.credentialSubject?.id === undefined) {
286
+ vc.credentialSubject.id = sub;
287
+ } else {
288
+ throw new BedrockError(
289
+ 'VC-JWT "sub" claim does not equal nor does it exclusively ' +
290
+ 'provide verifiable credential "credentialSubject.id".', {
291
+ name: 'DataError',
292
+ details: {
293
+ httpStatusCode: 400,
294
+ public: true
295
+ }
296
+ });
297
+ }
298
+ }
299
+
300
+ if(nbf === undefined && isVersion1) {
301
+ throw new BedrockError('JWT validation failed.', {
302
+ name: 'DataError',
303
+ details: {
304
+ httpStatusCode: 400,
305
+ public: true,
306
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
307
+ reason: 'missing "nbf" claim value.',
308
+ claim: 'nbf'
309
+ }
310
+ });
311
+ }
312
+
313
+ if(nbf !== undefined) {
314
+ // fuzzy convert `nbf` into `issuanceDate` / `validFrom`, only require
315
+ // second-level precision
316
+ const dateString = new Date(nbf * 1000).toISOString().slice(0, -5);
317
+ const dateProperty = isVersion1 ? 'issuanceDate' : 'validFrom';
318
+ // inject dateProperty value
319
+ if(vc[dateProperty] === undefined) {
320
+ vc[dateProperty] = dateString + 'Z';
321
+ } else if(!(vc[dateProperty].startsWith(dateString) &&
322
+ vc[dateProperty].endsWith('Z'))) {
323
+ throw new BedrockError(
324
+ 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' +
325
+ `verifiable credential "${dateProperty}".`, {
326
+ name: 'DataError',
327
+ details: {
328
+ httpStatusCode: 400,
329
+ public: true
330
+ }
331
+ });
332
+ }
333
+ }
334
+
335
+ if(exp !== undefined) {
336
+ // fuzzy convert `exp` into `expirationDate` / `validUntil`, only require
337
+ // second-level precision
338
+ const dateString = new Date(exp * 1000).toISOString().slice(0, -5);
339
+ const dateProperty = isVersion1 ? 'expirationDate' : 'validUntil';
340
+ // inject dateProperty value
341
+ if(vc[dateProperty] === undefined) {
342
+ vc[dateProperty] = dateString + 'Z';
343
+ } else if(!(vc[dateProperty].startsWith(dateString) &&
344
+ vc[dateProperty].endsWith('Z'))) {
345
+ throw new BedrockError(
346
+ 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' +
347
+ `verifiable credential "${dateProperty}".`, {
348
+ name: 'DataError',
349
+ details: {
350
+ httpStatusCode: 400,
351
+ public: true
352
+ }
353
+ });
354
+ }
355
+ }
356
+
357
+ return credential;
358
+ }
359
+
360
+ function _jwtPayloadToPresentation({verifyResult, challenge} = {}) {
361
+ /* Example:
362
+ {
363
+ "alg": <signer.algorithm>,
364
+ "kid": <signer.id>
365
+ }.
366
+ {
367
+ "iss": <verifiablePresentation.holder>,
368
+ "aud": <verifiablePresentation.domain>,
369
+ "nonce": <verifiablePresentation.nonce>,
370
+ "jti": <verifiablePresentation.id>
371
+ "nbf": <verifiablePresentation.[validFrom]>
372
+ "exp": <verifiablePresentation.[validUntil]>
373
+ "vp": <verifiablePresentation>
374
+ }
375
+ */
376
+ const {vp} = verifyResult.payload;
377
+ if(!(vp && typeof vp === 'object')) {
378
+ throw new BedrockError('JWT validation failed.', {
379
+ name: 'DataError',
380
+ details: {
381
+ httpStatusCode: 400,
382
+ public: true,
383
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
384
+ reason: 'missing or unexpected "vp" claim value.',
385
+ claim: 'vp'
386
+ }
387
+ });
388
+ }
389
+
390
+ let {'@context': context = []} = vp;
391
+ if(!Array.isArray(context)) {
392
+ context = [context];
393
+ }
394
+ const isVersion1 = context.includes(VC_CONTEXT_1);
395
+ const isVersion2 = context.includes(VC_CONTEXT_2);
396
+ if(!(isVersion1 ^ isVersion2)) {
397
+ throw new BedrockError(
398
+ 'Verifiable presentation is not either version "1.x" or "2.x".', {
399
+ name: 'DataError',
400
+ details: {
401
+ httpStatusCode: 400,
402
+ public: true
403
+ }
404
+ });
405
+ }
406
+
407
+ const presentation = {...vp};
408
+ const {iss, nonce, jti, nbf, exp} = verifyResult.payload;
409
+
410
+ // inject `holder` value
411
+ if(vp.holder === undefined) {
412
+ vp.holder = iss;
413
+ } else if(vp.holder && typeof vp.holder === 'object' &&
414
+ vp.holder.id === undefined) {
415
+ vp.holder.id = iss;
416
+ } else if(iss !== vp.holder && iss !== vp.holder?.id) {
417
+ throw new BedrockError(
418
+ 'VC-JWT "iss" claim does not equal nor does it exclusively ' +
419
+ 'provide verifiable presentation "holder" / "holder.id".', {
420
+ name: 'DataError',
421
+ details: {
422
+ httpStatusCode: 400,
423
+ public: true
424
+ }
425
+ });
426
+ }
427
+
428
+ if(jti !== undefined && jti !== vp.id) {
429
+ // inject `id` value
430
+ if(vp.id === undefined) {
431
+ vp.id = jti;
432
+ } else {
433
+ throw new BedrockError(
434
+ 'VC-JWT "jti" claim does not equal nor does it exclusively ' +
435
+ 'provide verifiable presentation "id".', {
436
+ name: 'DataError',
437
+ details: {
438
+ httpStatusCode: 400,
439
+ public: true
440
+ }
441
+ });
442
+ }
443
+ }
444
+
445
+ // version 1.x VPs do not support `validFrom`/`validUntil`
446
+ if(nbf !== undefined && isVersion2) {
447
+ // fuzzy convert `nbf` into `validFrom`, only require
448
+ // second-level precision
449
+ const dateString = new Date(nbf * 1000).toISOString().slice(0, -5);
450
+
451
+ // inject `validFrom` value
452
+ if(vp.validFrom === undefined) {
453
+ vp.validFrom = dateString + 'Z';
454
+ } else if(!(vp.validFrom?.startsWith(dateString) &&
455
+ vp.validFrom.endsWith('Z'))) {
456
+ throw new BedrockError(
457
+ 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' +
458
+ 'verifiable presentation "validFrom".', {
459
+ name: 'DataError',
460
+ details: {
461
+ httpStatusCode: 400,
462
+ public: true
463
+ }
464
+ });
465
+ }
466
+ }
467
+ if(exp !== undefined && isVersion2) {
468
+ // fuzzy convert `exp` into `validUntil`, only require
469
+ // second-level precision
470
+ const dateString = new Date(exp * 1000).toISOString().slice(0, -5);
471
+
472
+ // inject `validUntil` value
473
+ if(vp.validUntil === undefined) {
474
+ vp.validUntil = dateString + 'Z';
475
+ } else if(!(vp.validUntil?.startsWith(dateString) &&
476
+ vp.validUntil?.endsWith('Z'))) {
477
+ throw new BedrockError(
478
+ 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' +
479
+ 'verifiable presentation "validUntil".', {
480
+ name: 'DataError',
481
+ details: {
482
+ httpStatusCode: 400,
483
+ public: true
484
+ }
485
+ });
486
+ }
487
+ }
488
+
489
+ if(challenge !== undefined && nonce !== challenge) {
490
+ throw new BedrockError('JWT validation failed.', {
491
+ name: 'DataError',
492
+ details: {
493
+ httpStatusCode: 400,
494
+ public: true,
495
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
496
+ reason: 'missing or unexpected "nonce" claim value.',
497
+ claim: 'nonce'
498
+ }
499
+ });
500
+ }
501
+
502
+ // do some validation on `verifiableCredential`
503
+ let {verifiableCredential = []} = presentation;
504
+ if(!Array.isArray(verifiableCredential)) {
505
+ verifiableCredential = [verifiableCredential];
506
+ }
507
+
508
+ // ensure version 2 VPs only have objects in `verifiableCredential`
509
+ const hasVCJWTs = verifiableCredential.some(vc => typeof vc !== 'object');
510
+ if(isVersion2 && hasVCJWTs) {
511
+ throw new BedrockError(
512
+ 'Version 2.x verifiable presentations must only use objects in the ' +
513
+ '"verifiableCredential" field.', {
514
+ name: 'DataError',
515
+ details: {
516
+ httpStatusCode: 400,
517
+ public: true
518
+ }
519
+ });
520
+ }
521
+
522
+ // transform any VC-JWT VCs to enveloped VCs
523
+ if(presentation.verifiableCredential && hasVCJWTs) {
524
+ presentation.verifiableCredential = verifiableCredential.map(vc => {
525
+ if(typeof vc !== 'string') {
526
+ return vc;
527
+ }
528
+ return {
529
+ '@context': VC_CONTEXT_2,
530
+ id: `data:application/jwt,${vc}`,
531
+ type: 'EnvelopedVerifiableCredential',
532
+ };
533
+ });
534
+ }
535
+
536
+ return presentation;
537
+ }
package/lib/verify.js ADDED
@@ -0,0 +1,86 @@
1
+ /*!
2
+ * Copyright (c) 2018-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as di from './di.js';
5
+ import {
6
+ verifyEnvelopedCredential, verifyEnvelopedPresentation
7
+ } from './envelopes.js';
8
+
9
+ export async function verifyCredential({config, credential, checks} = {}) {
10
+ if(credential?.type !== 'EnvelopedVerifiableCredential') {
11
+ return di.verifyCredential({config, credential, checks});
12
+ }
13
+
14
+ const result = await verifyEnvelopedCredential({
15
+ envelopedCredential: credential, checks
16
+ });
17
+ // if the credential has a `proof` field, do DI verification
18
+ let {verified} = result;
19
+ if(verified && result.credential.proof) {
20
+ const proofResult = await di.verifyCredential({
21
+ config, credential: result.credential, checks
22
+ });
23
+ result.proofResult = proofResult;
24
+ verified = verified && proofResult.verified;
25
+ }
26
+ return {...result, verified};
27
+ }
28
+
29
+ export async function verifyPresentation({
30
+ config, presentation, challenge, domain, checks
31
+ } = {}) {
32
+ if(presentation?.type !== 'EnvelopedVerifiablePresentation') {
33
+ return di.verifyPresentation({
34
+ config, presentation, challenge, domain, checks
35
+ });
36
+ }
37
+
38
+ const presentationResult = await verifyEnvelopedPresentation({
39
+ envelopedPresentation: presentation, challenge, domain
40
+ });
41
+ // verify each `verifiableCredential` in the resulting VP
42
+ let verified = presentationResult.verified;
43
+ let credentialResults;
44
+ if(!verified) {
45
+ credentialResults = [];
46
+ } else {
47
+ // if the presentation has a `proof` field, do DI verification, but
48
+ // note that the presentation itself may verify but the VCs therein might
49
+ // not because some of them might be enveloped VCs and the underlying
50
+ // `vc` library doesn't support this; therefore only use the presentation
51
+ // result and let the code below check VCs to ensure any enveloped VCs
52
+ // will also be checked
53
+ if(presentationResult.presentation.proof) {
54
+ const proofResult = await di.verifyPresentation({
55
+ config, presentation: presentationResult.presentation,
56
+ challenge, domain, checks
57
+ });
58
+ presentationResult.proofResult = proofResult;
59
+ verified = !!(verified && proofResult.presentationResult?.verified);
60
+ if(proofResult.verified) {
61
+ // the whole VP was verified, so include the credential results, no
62
+ // need to repeat below to ensure enveloped credentials are checked
63
+ // as there aren't any
64
+ credentialResults = proofResult.credentialResults;
65
+ }
66
+ }
67
+
68
+ if(!credentialResults) {
69
+ // verify each VC in the VP
70
+ let {verifiableCredential = []} = presentationResult.presentation;
71
+ if(!Array.isArray(verifiableCredential)) {
72
+ verifiableCredential = [verifiableCredential];
73
+ }
74
+ credentialResults = await Promise.all(verifiableCredential.map(
75
+ credential => verifyCredential({config, credential, checks})));
76
+ verified = verified && credentialResults.every(
77
+ ({verified}) => verified);
78
+ }
79
+ }
80
+ return {
81
+ ...presentationResult,
82
+ verified,
83
+ presentationResult,
84
+ credentialResults
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrock/vc-verifier",
3
- "version": "20.0.0",
3
+ "version": "20.1.0",
4
4
  "type": "module",
5
5
  "description": "Bedrock VC Verifier",
6
6
  "main": "./lib/index.js",
@@ -28,8 +28,10 @@
28
28
  "@digitalbazaar/bbs-2023-cryptosuite": "^1.2.0",
29
29
  "@digitalbazaar/data-integrity": "^2.2.0",
30
30
  "@digitalbazaar/ecdsa-2019-cryptosuite": "^2.0.0",
31
+ "@digitalbazaar/ecdsa-multikey": "^1.7.0",
31
32
  "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.1.0",
32
33
  "@digitalbazaar/ecdsa-sd-2023-cryptosuite": "^3.2.1",
34
+ "@digitalbazaar/ed25519-multikey": "^1.1.0",
33
35
  "@digitalbazaar/ed25519-signature-2018": "^4.0.0",
34
36
  "@digitalbazaar/ed25519-signature-2020": "^5.4.0",
35
37
  "@digitalbazaar/eddsa-2022-cryptosuite": "^1.0.0",
@@ -41,6 +43,7 @@
41
43
  "bnid": "^3.0.0",
42
44
  "body-parser": "^1.20.2",
43
45
  "cors": "^2.8.5",
46
+ "jose": "^5.6.3",
44
47
  "klona": "^2.0.6",
45
48
  "serialize-error": "^11.0.3"
46
49
  },
@@ -1,12 +1,177 @@
1
1
  /*!
2
2
  * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
- const context = {
5
- title: '@context',
4
+ import {schemas} from '@bedrock/validation';
5
+
6
+ const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1';
7
+ const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
8
+
9
+ const vcContext = {
6
10
  type: 'array',
7
11
  minItems: 1,
8
- items: {
9
- type: ['string', 'object']
12
+ // the first context must be the VC context
13
+ items: [{
14
+ oneOf: [{
15
+ const: VC_CONTEXT_1
16
+ }, {
17
+ const: VC_CONTEXT_2
18
+ }]
19
+ }],
20
+ // additional contexts maybe strings or objects
21
+ additionalItems: {
22
+ anyOf: [{type: 'string'}, {type: 'object'}]
23
+ }
24
+ };
25
+
26
+ function idOrObjectWithId() {
27
+ return {
28
+ title: 'identifier or an object with an id',
29
+ anyOf: [
30
+ schemas.identifier(),
31
+ {
32
+ type: 'object',
33
+ required: ['id'],
34
+ additionalProperties: true,
35
+ properties: {id: schemas.identifier()}
36
+ }
37
+ ]
38
+ };
39
+ }
40
+
41
+ function verifiableCredential() {
42
+ return {
43
+ title: 'Verifiable Credential',
44
+ type: 'object',
45
+ required: [
46
+ '@context',
47
+ 'credentialSubject',
48
+ 'issuer',
49
+ 'type'
50
+ ],
51
+ additionalProperties: true,
52
+ properties: {
53
+ '@context': vcContext,
54
+ credentialSubject: {
55
+ anyOf: [
56
+ {type: 'object'},
57
+ {type: 'array', minItems: 1, items: {type: 'object'}}
58
+ ]
59
+ },
60
+ id: {
61
+ type: 'string'
62
+ },
63
+ issuer: idOrObjectWithId(),
64
+ type: {
65
+ type: 'array',
66
+ minItems: 1,
67
+ // this first type must be VerifiableCredential
68
+ items: [
69
+ {const: 'VerifiableCredential'},
70
+ ],
71
+ // additional types must be strings
72
+ additionalItems: {
73
+ type: 'string'
74
+ }
75
+ },
76
+ proof: schemas.proof()
77
+ }
78
+ };
79
+ }
80
+
81
+ const envelopedVerifiableCredential = {
82
+ title: 'Enveloped Verifiable Credential',
83
+ type: 'object',
84
+ additionalProperties: false,
85
+ required: ['@context', 'id', 'type'],
86
+ properties: {
87
+ '@context': {
88
+ anyOf: [{
89
+ const: VC_CONTEXT_2
90
+ }, {
91
+ type: 'array',
92
+ minItems: 1,
93
+ maxItems: 1,
94
+ // the first context must be the VC context
95
+ items: [{
96
+ const: VC_CONTEXT_2
97
+ }]
98
+ }]
99
+ },
100
+ id: {
101
+ type: 'string'
102
+ },
103
+ type: {
104
+ const: 'EnvelopedVerifiableCredential'
105
+ }
106
+ }
107
+ };
108
+
109
+ export function verifiablePresentation() {
110
+ return {
111
+ title: 'Verifiable Presentation',
112
+ type: 'object',
113
+ required: ['@context', 'type'],
114
+ additionalProperties: true,
115
+ properties: {
116
+ '@context': vcContext,
117
+ id: {
118
+ type: 'string'
119
+ },
120
+ type: {
121
+ type: 'array',
122
+ minItems: 1,
123
+ // this first type must be VerifiablePresentation
124
+ items: [
125
+ {const: 'VerifiablePresentation'},
126
+ ],
127
+ // additional types must be strings
128
+ additionalItems: {
129
+ type: 'string'
130
+ }
131
+ },
132
+ verifiableCredential: {
133
+ anyOf: [
134
+ verifiableCredential(),
135
+ envelopedVerifiableCredential, {
136
+ type: 'array',
137
+ minItems: 1,
138
+ items: {
139
+ anyOf: [verifiableCredential(), envelopedVerifiableCredential]
140
+ }
141
+ }
142
+ ]
143
+ },
144
+ holder: idOrObjectWithId(),
145
+ proof: schemas.proof()
146
+ }
147
+ };
148
+ }
149
+
150
+ const envelopedVerifiablePresentation = {
151
+ title: 'Enveloped Verifiable Presentation',
152
+ type: 'object',
153
+ additionalProperties: false,
154
+ required: ['@context', 'id', 'type'],
155
+ properties: {
156
+ '@context': {
157
+ anyOf: [{
158
+ const: VC_CONTEXT_2
159
+ }, {
160
+ type: 'array',
161
+ minItems: 1,
162
+ maxItems: 1,
163
+ // the first context must be the VC context
164
+ items: [{
165
+ const: VC_CONTEXT_2
166
+ }]
167
+ }]
168
+ },
169
+ id: {
170
+ type: 'string'
171
+ },
172
+ type: {
173
+ const: 'EnvelopedVerifiablePresentation'
174
+ }
10
175
  }
11
176
  };
12
177
 
@@ -54,42 +219,42 @@ export const createChallengeBody = {
54
219
  properties: {}
55
220
  };
56
221
 
57
- export const verifyCredentialBody = {
58
- title: 'Verify Credential Body',
59
- type: 'object',
60
- required: ['verifiableCredential'],
61
- additionalProperties: false,
62
- properties: {
63
- options: {
64
- type: 'object'
65
- },
66
- verifiableCredential: {
67
- type: 'object',
68
- additionalProperties: true,
69
- required: ['@context'],
70
- properties: {
71
- '@context': context
222
+ export function verifyCredentialBody() {
223
+ return {
224
+ title: 'Verify Credential Body',
225
+ type: 'object',
226
+ required: ['verifiableCredential'],
227
+ additionalProperties: false,
228
+ properties: {
229
+ options: {
230
+ type: 'object'
231
+ },
232
+ verifiableCredential: {
233
+ anyOf: [
234
+ verifiableCredential(),
235
+ envelopedVerifiableCredential
236
+ ]
72
237
  }
73
238
  }
74
- }
75
- };
239
+ };
240
+ }
76
241
 
77
- export const verifyPresentationBody = {
78
- title: 'Verify Presentation Body',
79
- type: 'object',
80
- required: ['verifiablePresentation'],
81
- additionalProperties: false,
82
- properties: {
83
- options: {
84
- type: 'object'
85
- },
86
- verifiablePresentation: {
87
- type: 'object',
88
- additionalProperties: true,
89
- required: ['@context'],
90
- properties: {
91
- '@context': context
242
+ export function verifyPresentationBody() {
243
+ return {
244
+ title: 'Verify Presentation Body',
245
+ type: 'object',
246
+ required: ['verifiablePresentation'],
247
+ additionalProperties: false,
248
+ properties: {
249
+ options: {
250
+ type: 'object'
251
+ },
252
+ verifiablePresentation: {
253
+ anyOf: [
254
+ verifiablePresentation(),
255
+ envelopedVerifiablePresentation
256
+ ]
92
257
  }
93
258
  }
94
- }
95
- };
259
+ };
260
+ }