@bedrock/vc-verifier 19.1.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/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
+ }