@sd-jwt/sd-jwt-vc 0.17.2-next.0 → 0.17.2-next.10

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.
@@ -6,15 +6,21 @@ import {
6
6
  type StatusListJWTPayload,
7
7
  } from '@sd-jwt/jwt-status-list';
8
8
  import type { DisclosureFrame, Hasher, Verifier } from '@sd-jwt/types';
9
- import { base64urlDecode, SDJWTException } from '@sd-jwt/utils';
9
+ import { SDJWTException } from '@sd-jwt/utils';
10
+ import z from 'zod';
10
11
  import type {
11
12
  SDJWTVCConfig,
12
13
  StatusListFetcher,
13
14
  StatusValidator,
14
15
  } from './sd-jwt-vc-config';
15
16
  import type { SdJwtVcPayload } from './sd-jwt-vc-payload';
16
- import type { TypeMetadataFormat } from './sd-jwt-vc-type-metadata-format';
17
- import type { VcTFetcher } from './sd-jwt-vc-vct';
17
+ import {
18
+ type Claim,
19
+ type ClaimPath,
20
+ type ResolvedTypeMetadata,
21
+ type TypeMetadataFormat,
22
+ TypeMetadataFormatSchema,
23
+ } from './sd-jwt-vc-type-metadata-format';
18
24
  import type { VerificationResult } from './verification-result';
19
25
 
20
26
  export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
@@ -121,17 +127,23 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
121
127
 
122
128
  await this.verifyStatus(result, options);
123
129
  if (this.userConfig.loadTypeMetadataFormat) {
124
- await this.verifyVct(result);
130
+ const resolvedTypeMetadata = await this.fetchVct(result);
131
+ result.typeMetadata = resolvedTypeMetadata;
125
132
  }
126
133
  return result;
127
134
  }
128
135
 
129
136
  /**
130
137
  * Gets VCT Metadata of the raw SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC is invalid or does not contain a vct claim, an error is thrown.
138
+ *
139
+ * It may return `undefined` if the fetcher returned an undefined value (instead of throwing an error).
140
+ *
131
141
  * @param encodedSDJwt
132
142
  * @returns
133
143
  */
134
- async getVct(encodedSDJwt: string): Promise<TypeMetadataFormat> {
144
+ async getVct(
145
+ encodedSDJwt: string,
146
+ ): Promise<ResolvedTypeMetadata | undefined> {
135
147
  // Call the parent class's verify method
136
148
  const { payload, header } = await SDJwt.extractJwt<
137
149
  Record<string, unknown>,
@@ -161,24 +173,24 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
161
173
  url: string,
162
174
  integrity?: string,
163
175
  ) {
164
- if (integrity) {
165
- // validate the integrity of the response according to https://www.w3.org/TR/SRI/
166
- const arrayBuffer = await response.arrayBuffer();
167
- const alg = integrity.split('-')[0];
168
- //TODO: error handling when a hasher is passed that is not supporting the required algorithm acording to the spec
169
- const hashBuffer = await (this.userConfig.hasher as Hasher)(
170
- arrayBuffer,
171
- alg,
176
+ if (!integrity) return;
177
+
178
+ // validate the integrity of the response according to https://www.w3.org/TR/SRI/
179
+ const arrayBuffer = await response.arrayBuffer();
180
+ const alg = integrity.split('-')[0];
181
+ //TODO: error handling when a hasher is passed that is not supporting the required algorithm according to the spec
182
+ const hashBuffer = await (this.userConfig.hasher as Hasher)(
183
+ arrayBuffer,
184
+ alg,
185
+ );
186
+ const integrityHash = integrity.split('-')[1];
187
+ const hash = Array.from(new Uint8Array(hashBuffer))
188
+ .map((byte) => byte.toString(16).padStart(2, '0'))
189
+ .join('');
190
+ if (hash !== integrityHash) {
191
+ throw new Error(
192
+ `Integrity check for ${url} failed: is ${hash}, but expected ${integrityHash}`,
172
193
  );
173
- const integrityHash = integrity.split('-')[1];
174
- const hash = Array.from(new Uint8Array(hashBuffer))
175
- .map((byte) => byte.toString(16).padStart(2, '0'))
176
- .join('');
177
- if (hash !== integrityHash) {
178
- throw new Error(
179
- `Integrity check for ${url} failed: is ${hash}, but expected ${integrityHash}`,
180
- );
181
- }
182
194
  }
183
195
  }
184
196
 
@@ -187,7 +199,10 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
187
199
  * @param url
188
200
  * @returns
189
201
  */
190
- private async fetch<T>(url: string, integrity?: string): Promise<T> {
202
+ private async fetchWithIntegrity(
203
+ url: string,
204
+ integrity?: string,
205
+ ): Promise<unknown> {
191
206
  try {
192
207
  const response = await fetch(url, {
193
208
  signal: AbortSignal.timeout(this.userConfig.timeout ?? 10000),
@@ -199,7 +214,9 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
199
214
  );
200
215
  }
201
216
  await this.validateIntegrity(response.clone(), url, integrity);
202
- return response.json() as Promise<T>;
217
+ const data = await response.json();
218
+
219
+ return data;
203
220
  } catch (error) {
204
221
  if ((error as Error).name === 'TimeoutError') {
205
222
  throw new Error(`Request to ${url} timed out`);
@@ -210,75 +227,266 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
210
227
 
211
228
  /**
212
229
  * Verifies the VCT of the SD-JWT-VC. Returns the type metadata format.
230
+ * Resolves the full extends chain according to spec sections 6.4, 8.2, and 9.5.
213
231
  * @param result
214
232
  * @returns
215
233
  */
216
- private async verifyVct(
234
+ private async fetchVct(
217
235
  result: VerificationResult,
218
- ): Promise<TypeMetadataFormat | undefined> {
219
- const typeMetadataFormat = await this.fetchVct(result);
236
+ ): Promise<ResolvedTypeMetadata | undefined> {
237
+ const typeMetadataFormat = await this.fetchSingleVct(
238
+ result.payload.vct,
239
+ result.payload['vct#integrity'],
240
+ );
241
+
242
+ if (!typeMetadataFormat) return undefined;
243
+
244
+ // If there's no extends
245
+ if (!typeMetadataFormat.extends) {
246
+ return {
247
+ mergedTypeMetadata: typeMetadataFormat,
248
+ typeMetadataChain: [typeMetadataFormat],
249
+ vctValues: [typeMetadataFormat.vct],
250
+ };
251
+ }
252
+
253
+ // Resolve the full VCT chain if extends is present
254
+ return this.resolveVctExtendsChain(typeMetadataFormat);
255
+ }
220
256
 
221
- if (typeMetadataFormat.extends) {
222
- // implement based on https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-08.html#name-extending-type-metadata
223
- //TODO: needs to be implemented. Unclear at this point which values will overwrite the values from the extended type metadata format
257
+ /**
258
+ * Checks if two claim paths are equal by comparing each element.
259
+ * @param path1 First claim path
260
+ * @param path2 Second claim path
261
+ * @returns True if paths are equal, false otherwise
262
+ */
263
+ private claimPathsEqual(path1: ClaimPath, path2: ClaimPath): boolean {
264
+ if (path1.length !== path2.length) return false;
265
+ return path1.every((element, index) => element === path2[index]);
266
+ }
267
+
268
+ /**
269
+ * Validates that extending claim metadata respects the constraints from spec section 9.5.1.
270
+ * @param baseClaim The base claim metadata
271
+ * @param extendingClaim The extending claim metadata
272
+ * @throws SDJWTException if validation fails
273
+ */
274
+ private validateClaimExtension(
275
+ baseClaim: Claim,
276
+ extendingClaim: Claim,
277
+ ): void {
278
+ // Validate 'sd' property constraints (section 9.5.1)
279
+ if (baseClaim.sd && extendingClaim.sd) {
280
+ // Cannot change from 'always' or 'never' to a different value
281
+ if (
282
+ (baseClaim.sd === 'always' || baseClaim.sd === 'never') &&
283
+ baseClaim.sd !== extendingClaim.sd
284
+ ) {
285
+ const pathStr = JSON.stringify(extendingClaim.path);
286
+ throw new SDJWTException(
287
+ `Cannot change 'sd' property from '${baseClaim.sd}' to '${extendingClaim.sd}' for claim at path ${pathStr}`,
288
+ );
289
+ }
224
290
  }
225
- return typeMetadataFormat;
226
291
  }
227
292
 
228
293
  /**
229
- * Fetches VCT Metadata of the SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC does not contain a vct claim, an error is thrown.
230
- * @param result
231
- * @returns
294
+ * Merges two type metadata formats, with the extending metadata overriding the base metadata.
295
+ * According to spec section 9.5:
296
+ * - All claim metadata from the extended type are inherited
297
+ * - The child type can add new claims or properties
298
+ * - If the child type defines claim metadata with the same path as the extended type,
299
+ * the child type's object will override the corresponding object from the extended type
300
+ * According to spec section 9.5.1:
301
+ * - sd property can only be changed from 'allowed' (or omitted) to 'always' or 'never'
302
+ * - sd property cannot be changed from 'always' or 'never' to a different value
303
+ * According to spec section 8.2:
304
+ * - If the extending type defines its own display property, the original display metadata is ignored
305
+ * Note: The spec also mentions 'mandatory' property constraints, but this is not currently
306
+ * defined in the Claim type and will be validated when that property is added to the type.
307
+ * @param base The base type metadata format
308
+ * @param extending The extending type metadata format
309
+ * @returns The merged type metadata format
232
310
  */
233
- private async fetchVct(
234
- result: VerificationResult,
235
- ): Promise<TypeMetadataFormat> {
236
- if (!result.payload.vct) {
237
- throw new SDJWTException('vct claim is required');
311
+ private mergeTypeMetadata(
312
+ base: TypeMetadataFormat,
313
+ extending: TypeMetadataFormat,
314
+ ): TypeMetadataFormat {
315
+ // Start with a shallow copy of the extending metadata
316
+ // All properties that don't have explicit processing logic for merging
317
+ // will only be shallow copied, and the extending metadata will take precedence.
318
+ const merged: TypeMetadataFormat = { ...extending };
319
+
320
+ // Merge claims arrays if both exist
321
+ if (base.claims || extending.claims) {
322
+ const baseClaims = base.claims ?? [];
323
+ const extendingClaims = extending.claims ?? [];
324
+
325
+ // Validate extending claims that override base claims
326
+ for (const extendingClaim of extendingClaims) {
327
+ const matchingBaseClaim = baseClaims.find((baseClaim) =>
328
+ this.claimPathsEqual(baseClaim.path, extendingClaim.path),
329
+ );
330
+
331
+ if (matchingBaseClaim) {
332
+ this.validateClaimExtension(matchingBaseClaim, extendingClaim);
333
+ }
334
+ }
335
+
336
+ // Build final claims array preserving order
337
+ // Start with base claims, replacing any that are overridden
338
+ const mergedClaims: typeof baseClaims = [];
339
+ const extendedClaimsWithoutBase = [...extendingClaims];
340
+
341
+ // Add base claims, replacing with extending version if path matches
342
+ for (const baseClaim of baseClaims) {
343
+ const extendingClaimIndex = extendedClaimsWithoutBase.findIndex(
344
+ (extendingClaim) =>
345
+ this.claimPathsEqual(baseClaim.path, extendingClaim.path),
346
+ );
347
+ const extendingClaim =
348
+ extendingClaimIndex !== -1
349
+ ? extendedClaimsWithoutBase[extendingClaimIndex]
350
+ : undefined;
351
+
352
+ // Remove item from the array
353
+ if (extendingClaim) {
354
+ extendedClaimsWithoutBase.splice(extendingClaimIndex, 1);
355
+ }
356
+
357
+ // Prefer extending claim, otherwise use base claim
358
+ mergedClaims.push(extendingClaim ?? baseClaim);
359
+ }
360
+
361
+ // Add all remaining claims at the end
362
+ mergedClaims.push(...extendedClaimsWithoutBase);
363
+
364
+ merged.claims = mergedClaims;
238
365
  }
239
366
 
240
- if (result.header?.vctm) {
241
- return this.fetchVctFromHeader(result.payload.vct, result);
367
+ // Handle display metadata (section 8.2)
368
+ // If extending type doesn't define display, inherit from base
369
+ if (!extending.display && base.display) {
370
+ merged.display = base.display;
242
371
  }
243
372
 
244
- const fetcher: VcTFetcher =
245
- this.userConfig.vctFetcher ??
246
- ((uri, integrity) => this.fetch(uri, integrity));
247
- return fetcher(result.payload.vct, result.payload['vct#Integrity']);
373
+ return merged;
248
374
  }
249
375
 
250
376
  /**
251
- * Fetches VCT Metadata from the header of the SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC does not contain a vct claim, an error is thrown.
252
- * @param result
253
- * @param
377
+ * Resolves the full VCT chain by recursively fetching extended type metadata.
378
+ * Implements security considerations from spec section 10.3 for circular dependencies.
379
+ * @param vct The VCT URI to resolve
380
+ * @param integrity Optional integrity metadata for the VCT
381
+ * @param depth Current depth in the chain
382
+ * @param visitedVcts Set of already visited VCT URIs to detect circular dependencies
383
+ * @returns The fully resolved and merged type metadata format
254
384
  */
255
- private async fetchVctFromHeader(
256
- vct: string,
257
- result: VerificationResult,
258
- ): Promise<TypeMetadataFormat> {
259
- const vctmHeader = result.header?.vctm;
385
+ private async resolveVctExtendsChain(
386
+ parentTypeMetadata: TypeMetadataFormat,
387
+ // We start at one, as the base is already fetched when this method is first called
388
+ depth: number = 1,
389
+ // By default include the parent vct, in case of the first call
390
+ visitedVcts: Set<string> = new Set(parentTypeMetadata.vct),
391
+ ): Promise<ResolvedTypeMetadata> {
392
+ const maxDepth = this.userConfig.maxVctExtendsDepth ?? 5;
393
+
394
+ // Check max depth (security consideration from spec section 10.3)
395
+ if (maxDepth !== -1 && depth > maxDepth) {
396
+ throw new SDJWTException(
397
+ `Maximum VCT extends depth of ${maxDepth} exceeded`,
398
+ );
399
+ }
260
400
 
261
- if (!vctmHeader || !Array.isArray(vctmHeader)) {
262
- throw new Error('vctm claim in SD JWT header is invalid');
401
+ if (!parentTypeMetadata.extends) {
402
+ throw new SDJWTException(
403
+ `Type metadata for vct '${parentTypeMetadata.vct}' has no 'extends' field. Unable to resolve extended type metadata document.`,
404
+ );
263
405
  }
264
406
 
265
- const typeMetadataFormat = (vctmHeader as unknown[])
266
- .map((vctm) => {
267
- if (!(typeof vctm === 'string')) {
268
- throw new Error('vctm claim in SD JWT header is invalid');
269
- }
407
+ // Check for circular dependencies (security consideration from spec section 10.3)
408
+ if (visitedVcts.has(parentTypeMetadata.extends)) {
409
+ throw new SDJWTException(
410
+ `Circular dependency detected in VCT extends chain: ${parentTypeMetadata.extends}`,
411
+ );
412
+ }
270
413
 
271
- return JSON.parse(base64urlDecode(vctm));
272
- })
273
- .find((typeMetadataFormat) => {
274
- return typeMetadataFormat.vct === vct;
275
- });
414
+ // Mark this VCT as visited
415
+ visitedVcts.add(parentTypeMetadata.extends);
416
+
417
+ const extendedTypeMetadata = await this.fetchSingleVct(
418
+ parentTypeMetadata.extends,
419
+ parentTypeMetadata['extends#integrity'],
420
+ );
421
+
422
+ // While top-level vct MAY return null (meaning there's no vct type metadata)
423
+ // The extends value ALWAYS must resolve to a value. A custom user provided resolver
424
+ // can return a minimal on-demand type metadata document if it wants to support this use case
425
+ if (!extendedTypeMetadata) {
426
+ throw new SDJWTException(
427
+ `Resolving VCT extends value '${parentTypeMetadata.extends}' resulted in an undefined result.`,
428
+ );
429
+ }
276
430
 
277
- if (!typeMetadataFormat) {
278
- throw new Error('could not find VCT Metadata in JWT header');
431
+ let resolvedTypeMetadata: ResolvedTypeMetadata;
432
+
433
+ // If this type extends another, recursively resolve the chain
434
+ // We MUST first process the lower level document before processing
435
+ // the higher level document
436
+ if (extendedTypeMetadata.extends) {
437
+ resolvedTypeMetadata = await this.resolveVctExtendsChain(
438
+ extendedTypeMetadata,
439
+ depth + 1,
440
+ visitedVcts,
441
+ );
442
+ } else {
443
+ resolvedTypeMetadata = {
444
+ mergedTypeMetadata: extendedTypeMetadata,
445
+ typeMetadataChain: [extendedTypeMetadata],
446
+ vctValues: [extendedTypeMetadata.vct],
447
+ };
448
+ }
449
+
450
+ const mergedTypeMetadata = this.mergeTypeMetadata(
451
+ resolvedTypeMetadata.mergedTypeMetadata,
452
+ parentTypeMetadata,
453
+ );
454
+
455
+ return {
456
+ mergedTypeMetadata: mergedTypeMetadata,
457
+ typeMetadataChain: [
458
+ parentTypeMetadata,
459
+ ...resolvedTypeMetadata.typeMetadataChain,
460
+ ],
461
+ vctValues: [parentTypeMetadata.vct, ...resolvedTypeMetadata.vctValues],
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Fetches and verifies the VCT Metadata for a VCT value.
467
+ * @param result
468
+ * @returns
469
+ */
470
+ private async fetchSingleVct(
471
+ vct: string,
472
+ integrity?: string,
473
+ ): Promise<TypeMetadataFormat | undefined> {
474
+ const fetcher =
475
+ this.userConfig.vctFetcher ??
476
+ ((uri, integrity) => this.fetchWithIntegrity(uri, integrity));
477
+
478
+ // Data may be undefined
479
+ const data = await fetcher(vct, integrity);
480
+ if (!data) return undefined;
481
+
482
+ const validated = TypeMetadataFormatSchema.safeParse(data);
483
+ if (!validated.success) {
484
+ throw new SDJWTException(
485
+ `Invalid VCT type metadata for vct '${vct}':\n${z.prettifyError(validated.error)}`,
486
+ );
279
487
  }
280
488
 
281
- return typeMetadataFormat;
489
+ return validated.data;
282
490
  }
283
491
 
284
492
  /**
@@ -13,7 +13,7 @@ export interface SdJwtVcPayload extends SdJwtPayload {
13
13
  // REQUIRED. The type of the Verifiable Credential, e.g., https://credentials.example.com/identity_credential, as defined in Section 3.2.2.1.1.
14
14
  vct: string;
15
15
  // OPTIONAL. If passed, the loaded type metadata format has to be validated according to https://www.w3.org/TR/SRI/
16
- 'vct#Integrity'?: string;
16
+ 'vct#integrity'?: string;
17
17
  // OPTIONAL. The information on how to read the status of the Verifiable Credential. See [https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html] for more information.
18
18
  status?: SDJWTVCStatusReference;
19
19
  // OPTIONAL. The identifier of the Subject of the Verifiable Credential. The Issuer MAY use it to provide the Subject identifier known by the Issuer. There is no requirement for a binding to exist between sub and cnf claims.