@sd-jwt/sd-jwt-vc 0.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/CHANGELOG.md +17 -0
- package/LICENSE +201 -0
- package/README.md +173 -0
- package/dist/index.d.mts +1106 -0
- package/dist/index.d.ts +1106 -0
- package/dist/index.js +773 -0
- package/dist/index.mjs +734 -0
- package/package.json +67 -0
- package/src/index.ts +7 -0
- package/src/sd-jwt-vc-config.ts +25 -0
- package/src/sd-jwt-vc-instance.ts +679 -0
- package/src/sd-jwt-vc-payload.ts +23 -0
- package/src/sd-jwt-vc-status-reference.ts +9 -0
- package/src/sd-jwt-vc-type-metadata-format.ts +271 -0
- package/src/sd-jwt-vc-vct.ts +6 -0
- package/src/test/index.spec.ts +180 -0
- package/src/test/vct.spec.ts +684 -0
- package/src/verification-result.ts +16 -0
- package/test/app-e2e.spec.ts +286 -0
- package/test/array_data_types.json +31 -0
- package/test/array_full_sd.json +21 -0
- package/test/array_in_sd.json +13 -0
- package/test/array_nested_in_plain.json +29 -0
- package/test/array_none_disclosed.json +17 -0
- package/test/array_of_nulls.json +15 -0
- package/test/array_of_objects.json +65 -0
- package/test/array_of_scalars.json +19 -0
- package/test/array_recursive_sd.json +35 -0
- package/test/array_recursive_sd_some_disclosed.json +63 -0
- package/test/complex.json +43 -0
- package/test/header_mod.json +48 -0
- package/test/json_serialization.json +48 -0
- package/test/key_binding.json +48 -0
- package/test/no_sd.json +36 -0
- package/test/object_data_types.json +62 -0
- package/test/recursions.json +117 -0
- package/tsconfig.json +7 -0
- package/vitest.config.mts +4 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getListFromStatusListJWT,
|
|
3
|
+
SLException,
|
|
4
|
+
type StatusListJWTHeaderParameters,
|
|
5
|
+
type StatusListJWTPayload,
|
|
6
|
+
} from '@owf/token-status-list';
|
|
7
|
+
import {
|
|
8
|
+
type DisclosureFrame,
|
|
9
|
+
ensureError,
|
|
10
|
+
Jwt,
|
|
11
|
+
type SafeVerifyResult,
|
|
12
|
+
SDJWTException,
|
|
13
|
+
SDJwt,
|
|
14
|
+
SDJwtInstance,
|
|
15
|
+
type VerificationError,
|
|
16
|
+
type VerificationErrorCode,
|
|
17
|
+
type VerifierOptions,
|
|
18
|
+
} from '@sd-jwt/core';
|
|
19
|
+
import z from 'zod';
|
|
20
|
+
import type {
|
|
21
|
+
SDJWTVCConfig,
|
|
22
|
+
StatusListFetcher,
|
|
23
|
+
StatusValidator,
|
|
24
|
+
} from './sd-jwt-vc-config';
|
|
25
|
+
import type { SdJwtVcPayload } from './sd-jwt-vc-payload';
|
|
26
|
+
import {
|
|
27
|
+
type Claim,
|
|
28
|
+
type ClaimPath,
|
|
29
|
+
type ResolvedTypeMetadata,
|
|
30
|
+
type TypeMetadataFormat,
|
|
31
|
+
TypeMetadataFormatSchema,
|
|
32
|
+
} from './sd-jwt-vc-type-metadata-format';
|
|
33
|
+
import type { VerificationResult } from './verification-result';
|
|
34
|
+
|
|
35
|
+
export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
|
|
36
|
+
/**
|
|
37
|
+
* The type of the SD-JWT-VC set in the header.typ field.
|
|
38
|
+
*/
|
|
39
|
+
protected type = 'dc+sd-jwt';
|
|
40
|
+
|
|
41
|
+
protected userConfig: SDJWTVCConfig = {};
|
|
42
|
+
|
|
43
|
+
constructor(userConfig?: SDJWTVCConfig) {
|
|
44
|
+
super(userConfig);
|
|
45
|
+
if (userConfig) {
|
|
46
|
+
this.userConfig = userConfig;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates if the disclosure frame attempts to selectively disclose protected SD-JWT-VC claims.
|
|
52
|
+
* @param disclosureFrame
|
|
53
|
+
*/
|
|
54
|
+
protected validateDisclosureFrame(
|
|
55
|
+
disclosureFrame?: DisclosureFrame<SdJwtVcPayload>,
|
|
56
|
+
): void {
|
|
57
|
+
if (
|
|
58
|
+
disclosureFrame?._sd &&
|
|
59
|
+
Array.isArray(disclosureFrame._sd) &&
|
|
60
|
+
disclosureFrame._sd.length > 0
|
|
61
|
+
) {
|
|
62
|
+
const reservedNames = ['iss', 'nbf', 'exp', 'cnf', 'vct', 'status'];
|
|
63
|
+
const reservedNamesInDisclosureFrame = disclosureFrame._sd.filter((key) =>
|
|
64
|
+
reservedNames.includes(String(key)),
|
|
65
|
+
);
|
|
66
|
+
if (reservedNamesInDisclosureFrame.length > 0) {
|
|
67
|
+
throw new SDJWTException('Cannot disclose protected field');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetches the status list from the uri with a timeout of 10 seconds.
|
|
74
|
+
* @param uri The URI to fetch from.
|
|
75
|
+
* @returns A promise that resolves to a compact JWT.
|
|
76
|
+
*/
|
|
77
|
+
private async statusListFetcher(uri: string): Promise<string> {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(uri, {
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
headers: { Accept: 'application/statuslist+jwt' },
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Error fetching status list: ${
|
|
89
|
+
response.status
|
|
90
|
+
} ${await response.text()}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// according to the spec the content type should be application/statuslist+jwt
|
|
95
|
+
if (
|
|
96
|
+
!response.headers
|
|
97
|
+
.get('content-type')
|
|
98
|
+
?.includes('application/statuslist+jwt')
|
|
99
|
+
) {
|
|
100
|
+
throw new Error('Invalid content type');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return response.text();
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validates the status, throws an error if the status is not 0.
|
|
111
|
+
* @param status
|
|
112
|
+
* @returns
|
|
113
|
+
*/
|
|
114
|
+
private async statusValidator(status: number): Promise<void> {
|
|
115
|
+
if (status !== 0) throw new SDJWTException('Status is not valid');
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verifies the SD-JWT-VC. It will validate the signature, the keybindings when required, the status, and the VCT.
|
|
121
|
+
* @param currentDate current time in seconds
|
|
122
|
+
*/
|
|
123
|
+
async verify(encodedSDJwt: string, options?: VerifierOptions) {
|
|
124
|
+
// Call the parent class's verify method
|
|
125
|
+
const result: VerificationResult = await super
|
|
126
|
+
.verify(encodedSDJwt, options)
|
|
127
|
+
.then((res) => {
|
|
128
|
+
return {
|
|
129
|
+
payload: res.payload as SdJwtVcPayload,
|
|
130
|
+
header: res.header,
|
|
131
|
+
kb: res.kb,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await this.verifyStatus(result, options);
|
|
136
|
+
if (this.userConfig.loadTypeMetadataFormat) {
|
|
137
|
+
const resolvedTypeMetadata = await this.fetchVct(result);
|
|
138
|
+
result.typeMetadata = resolvedTypeMetadata;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Safe verification that collects all errors instead of failing fast.
|
|
145
|
+
* Returns a result object with either the verified data or an array of all errors.
|
|
146
|
+
* This includes SD-JWT-VC specific validations like status and VCT metadata.
|
|
147
|
+
*
|
|
148
|
+
* @param encodedSDJwt - The encoded SD-JWT-VC to verify
|
|
149
|
+
* @param options - Verification options
|
|
150
|
+
* @returns A SafeVerifyResult containing either success data or collected errors
|
|
151
|
+
*/
|
|
152
|
+
async safeVerify(
|
|
153
|
+
encodedSDJwt: string,
|
|
154
|
+
options?: VerifierOptions,
|
|
155
|
+
): Promise<SafeVerifyResult<VerificationResult>> {
|
|
156
|
+
const errors: VerificationError[] = [];
|
|
157
|
+
|
|
158
|
+
// Helper to add errors
|
|
159
|
+
const addError = (
|
|
160
|
+
code: VerificationErrorCode,
|
|
161
|
+
message: string,
|
|
162
|
+
details?: unknown,
|
|
163
|
+
) => {
|
|
164
|
+
errors.push({ code, message, details });
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// First, call the parent's safeVerify to get base verification results
|
|
168
|
+
const baseResult = await super.safeVerify(encodedSDJwt, options);
|
|
169
|
+
|
|
170
|
+
// Collect errors from base verification
|
|
171
|
+
if (!baseResult.success) {
|
|
172
|
+
errors.push(...baseResult.errors);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build partial result for additional verifications
|
|
176
|
+
let result: VerificationResult | undefined;
|
|
177
|
+
if (baseResult.success) {
|
|
178
|
+
result = {
|
|
179
|
+
payload: baseResult.data.payload as SdJwtVcPayload,
|
|
180
|
+
header: baseResult.data.header,
|
|
181
|
+
kb: baseResult.data.kb as VerificationResult['kb'],
|
|
182
|
+
};
|
|
183
|
+
} else {
|
|
184
|
+
// Try to extract payload even if verification failed for status/vct checks
|
|
185
|
+
try {
|
|
186
|
+
const { payload, header } = await SDJwt.extractJwt<
|
|
187
|
+
Record<string, unknown>,
|
|
188
|
+
SdJwtVcPayload
|
|
189
|
+
>(encodedSDJwt);
|
|
190
|
+
if (payload) {
|
|
191
|
+
result = {
|
|
192
|
+
payload,
|
|
193
|
+
header,
|
|
194
|
+
kb: undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Cannot extract payload, skip additional checks
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Verify status (if payload is available)
|
|
203
|
+
if (result) {
|
|
204
|
+
try {
|
|
205
|
+
await this.verifyStatus(result, options);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const error = ensureError(e);
|
|
208
|
+
const errorMessage = error.message;
|
|
209
|
+
if (errorMessage.includes('Status is not valid')) {
|
|
210
|
+
addError('STATUS_INVALID', errorMessage, error);
|
|
211
|
+
} else {
|
|
212
|
+
addError(
|
|
213
|
+
'STATUS_VERIFICATION_FAILED',
|
|
214
|
+
`Status verification failed: ${errorMessage}`,
|
|
215
|
+
error,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Verify VCT metadata (if configured)
|
|
221
|
+
if (this.userConfig.loadTypeMetadataFormat) {
|
|
222
|
+
try {
|
|
223
|
+
const resolvedTypeMetadata = await this.fetchVct(result);
|
|
224
|
+
if (result) {
|
|
225
|
+
result.typeMetadata = resolvedTypeMetadata;
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
addError(
|
|
229
|
+
'VCT_VERIFICATION_FAILED',
|
|
230
|
+
`VCT verification failed: ${ensureError(e).message}`,
|
|
231
|
+
e,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Return result
|
|
238
|
+
if (errors.length > 0) {
|
|
239
|
+
return { success: false, errors };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!result) {
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
errors: [
|
|
246
|
+
{
|
|
247
|
+
code: 'INVALID_SD_JWT',
|
|
248
|
+
message: 'Failed to construct verification result',
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
data: result,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 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.
|
|
262
|
+
*
|
|
263
|
+
* It may return `undefined` if the fetcher returned an undefined value (instead of throwing an error).
|
|
264
|
+
*
|
|
265
|
+
* @param encodedSDJwt
|
|
266
|
+
* @returns
|
|
267
|
+
*/
|
|
268
|
+
async getVct(
|
|
269
|
+
encodedSDJwt: string,
|
|
270
|
+
): Promise<ResolvedTypeMetadata | undefined> {
|
|
271
|
+
// Call the parent class's verify method
|
|
272
|
+
const { payload, header } = await SDJwt.extractJwt<
|
|
273
|
+
Record<string, unknown>,
|
|
274
|
+
SdJwtVcPayload
|
|
275
|
+
>(encodedSDJwt);
|
|
276
|
+
|
|
277
|
+
if (!payload) {
|
|
278
|
+
throw new SDJWTException('JWT payload is missing');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const result: VerificationResult = {
|
|
282
|
+
payload,
|
|
283
|
+
header,
|
|
284
|
+
kb: undefined,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return this.fetchVct(result);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Validates the integrity of the response if the integrity is passed. If the integrity does not match, an error is thrown.
|
|
292
|
+
* @param integrity
|
|
293
|
+
* @param response
|
|
294
|
+
*/
|
|
295
|
+
private async validateIntegrity(
|
|
296
|
+
response: Response,
|
|
297
|
+
url: string,
|
|
298
|
+
integrity?: string,
|
|
299
|
+
) {
|
|
300
|
+
if (!integrity) return;
|
|
301
|
+
|
|
302
|
+
// validate the integrity of the response according to https://www.w3.org/TR/SRI/
|
|
303
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
304
|
+
const alg = integrity.split('-')[0];
|
|
305
|
+
//TODO: error handling when a hasher is passed that is not supporting the required algorithm according to the spec
|
|
306
|
+
if (!this.userConfig.hasher) {
|
|
307
|
+
throw new SDJWTException('Hasher not found');
|
|
308
|
+
}
|
|
309
|
+
const hashBuffer = await this.userConfig.hasher(arrayBuffer, alg);
|
|
310
|
+
const integrityHash = integrity.split('-')[1];
|
|
311
|
+
const hash = Array.from(new Uint8Array(hashBuffer))
|
|
312
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
313
|
+
.join('');
|
|
314
|
+
if (hash !== integrityHash) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Integrity check for ${url} failed: is ${hash}, but expected ${integrityHash}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Fetches the content from the url with a timeout of 10 seconds.
|
|
323
|
+
* @param url
|
|
324
|
+
* @returns
|
|
325
|
+
*/
|
|
326
|
+
private async fetchWithIntegrity(
|
|
327
|
+
url: string,
|
|
328
|
+
integrity?: string,
|
|
329
|
+
): Promise<unknown> {
|
|
330
|
+
try {
|
|
331
|
+
const response = await fetch(url, {
|
|
332
|
+
signal: AbortSignal.timeout(this.userConfig.timeout ?? 10000),
|
|
333
|
+
});
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
const errorText = await response.text();
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Error fetching ${url}: ${response.status} ${response.statusText} - ${errorText}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
await this.validateIntegrity(response.clone(), url, integrity);
|
|
341
|
+
const data = await response.json();
|
|
342
|
+
|
|
343
|
+
return data;
|
|
344
|
+
} catch (e) {
|
|
345
|
+
const error = ensureError(e);
|
|
346
|
+
if (error.name === 'TimeoutError') {
|
|
347
|
+
throw new Error(`Request to ${url} timed out`);
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Verifies the VCT of the SD-JWT-VC. Returns the type metadata format.
|
|
355
|
+
* Resolves the full extends chain according to spec sections 6.4, 8.2, and 9.5.
|
|
356
|
+
* @param result
|
|
357
|
+
* @returns
|
|
358
|
+
*/
|
|
359
|
+
private async fetchVct(
|
|
360
|
+
result: VerificationResult,
|
|
361
|
+
): Promise<ResolvedTypeMetadata | undefined> {
|
|
362
|
+
const typeMetadataFormat = await this.fetchSingleVct(
|
|
363
|
+
result.payload.vct,
|
|
364
|
+
result.payload['vct#integrity'],
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (!typeMetadataFormat) return undefined;
|
|
368
|
+
|
|
369
|
+
// If there's no extends
|
|
370
|
+
if (!typeMetadataFormat.extends) {
|
|
371
|
+
return {
|
|
372
|
+
mergedTypeMetadata: typeMetadataFormat,
|
|
373
|
+
typeMetadataChain: [typeMetadataFormat],
|
|
374
|
+
vctValues: [typeMetadataFormat.vct],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Resolve the full VCT chain if extends is present
|
|
379
|
+
return this.resolveVctExtendsChain(typeMetadataFormat);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Checks if two claim paths are equal by comparing each element.
|
|
384
|
+
* @param path1 First claim path
|
|
385
|
+
* @param path2 Second claim path
|
|
386
|
+
* @returns True if paths are equal, false otherwise
|
|
387
|
+
*/
|
|
388
|
+
private claimPathsEqual(path1: ClaimPath, path2: ClaimPath): boolean {
|
|
389
|
+
if (path1.length !== path2.length) return false;
|
|
390
|
+
return path1.every((element, index) => element === path2[index]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Validates that extending claim metadata respects the constraints from spec section 9.5.1.
|
|
395
|
+
* @param baseClaim The base claim metadata
|
|
396
|
+
* @param extendingClaim The extending claim metadata
|
|
397
|
+
* @throws SDJWTException if validation fails
|
|
398
|
+
*/
|
|
399
|
+
private validateClaimExtension(
|
|
400
|
+
baseClaim: Claim,
|
|
401
|
+
extendingClaim: Claim,
|
|
402
|
+
): void {
|
|
403
|
+
// Validate 'sd' property constraints (section 9.5.1)
|
|
404
|
+
if (baseClaim.sd && extendingClaim.sd) {
|
|
405
|
+
// Cannot change from 'always' or 'never' to a different value
|
|
406
|
+
if (
|
|
407
|
+
(baseClaim.sd === 'always' || baseClaim.sd === 'never') &&
|
|
408
|
+
baseClaim.sd !== extendingClaim.sd
|
|
409
|
+
) {
|
|
410
|
+
const pathStr = JSON.stringify(extendingClaim.path);
|
|
411
|
+
throw new SDJWTException(
|
|
412
|
+
`Cannot change 'sd' property from '${baseClaim.sd}' to '${extendingClaim.sd}' for claim at path ${pathStr}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Merges two type metadata formats, with the extending metadata overriding the base metadata.
|
|
420
|
+
* According to spec section 9.5:
|
|
421
|
+
* - All claim metadata from the extended type are inherited
|
|
422
|
+
* - The child type can add new claims or properties
|
|
423
|
+
* - If the child type defines claim metadata with the same path as the extended type,
|
|
424
|
+
* the child type's object will override the corresponding object from the extended type
|
|
425
|
+
* According to spec section 9.5.1:
|
|
426
|
+
* - sd property can only be changed from 'allowed' (or omitted) to 'always' or 'never'
|
|
427
|
+
* - sd property cannot be changed from 'always' or 'never' to a different value
|
|
428
|
+
* According to spec section 8.2:
|
|
429
|
+
* - If the extending type defines its own display property, the original display metadata is ignored
|
|
430
|
+
* Note: The spec also mentions 'mandatory' property constraints, but this is not currently
|
|
431
|
+
* defined in the Claim type and will be validated when that property is added to the type.
|
|
432
|
+
* @param base The base type metadata format
|
|
433
|
+
* @param extending The extending type metadata format
|
|
434
|
+
* @returns The merged type metadata format
|
|
435
|
+
*/
|
|
436
|
+
private mergeTypeMetadata(
|
|
437
|
+
base: TypeMetadataFormat,
|
|
438
|
+
extending: TypeMetadataFormat,
|
|
439
|
+
): TypeMetadataFormat {
|
|
440
|
+
// Start with a shallow copy of the extending metadata
|
|
441
|
+
// All properties that don't have explicit processing logic for merging
|
|
442
|
+
// will only be shallow copied, and the extending metadata will take precedence.
|
|
443
|
+
const merged: TypeMetadataFormat = { ...extending };
|
|
444
|
+
|
|
445
|
+
// Merge claims arrays if both exist
|
|
446
|
+
if (base.claims || extending.claims) {
|
|
447
|
+
const baseClaims = base.claims ?? [];
|
|
448
|
+
const extendingClaims = extending.claims ?? [];
|
|
449
|
+
|
|
450
|
+
// Validate extending claims that override base claims
|
|
451
|
+
for (const extendingClaim of extendingClaims) {
|
|
452
|
+
const matchingBaseClaim = baseClaims.find((baseClaim) =>
|
|
453
|
+
this.claimPathsEqual(baseClaim.path, extendingClaim.path),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
if (matchingBaseClaim) {
|
|
457
|
+
this.validateClaimExtension(matchingBaseClaim, extendingClaim);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Build final claims array preserving order
|
|
462
|
+
// Start with base claims, replacing any that are overridden
|
|
463
|
+
const mergedClaims: typeof baseClaims = [];
|
|
464
|
+
const extendedClaimsWithoutBase = [...extendingClaims];
|
|
465
|
+
|
|
466
|
+
// Add base claims, replacing with extending version if path matches
|
|
467
|
+
for (const baseClaim of baseClaims) {
|
|
468
|
+
const extendingClaimIndex = extendedClaimsWithoutBase.findIndex(
|
|
469
|
+
(extendingClaim) =>
|
|
470
|
+
this.claimPathsEqual(baseClaim.path, extendingClaim.path),
|
|
471
|
+
);
|
|
472
|
+
const extendingClaim =
|
|
473
|
+
extendingClaimIndex !== -1
|
|
474
|
+
? extendedClaimsWithoutBase[extendingClaimIndex]
|
|
475
|
+
: undefined;
|
|
476
|
+
|
|
477
|
+
// Remove item from the array
|
|
478
|
+
if (extendingClaim) {
|
|
479
|
+
extendedClaimsWithoutBase.splice(extendingClaimIndex, 1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Prefer extending claim, otherwise use base claim
|
|
483
|
+
mergedClaims.push(extendingClaim ?? baseClaim);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Add all remaining claims at the end
|
|
487
|
+
mergedClaims.push(...extendedClaimsWithoutBase);
|
|
488
|
+
|
|
489
|
+
merged.claims = mergedClaims;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Handle display metadata (section 8.2)
|
|
493
|
+
// If extending type doesn't define display, inherit from base
|
|
494
|
+
if (!extending.display && base.display) {
|
|
495
|
+
merged.display = base.display;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return merged;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Resolves the full VCT chain by recursively fetching extended type metadata.
|
|
503
|
+
* Implements security considerations from spec section 10.3 for circular dependencies.
|
|
504
|
+
* @param vct The VCT URI to resolve
|
|
505
|
+
* @param integrity Optional integrity metadata for the VCT
|
|
506
|
+
* @param depth Current depth in the chain
|
|
507
|
+
* @param visitedVcts Set of already visited VCT URIs to detect circular dependencies
|
|
508
|
+
* @returns The fully resolved and merged type metadata format
|
|
509
|
+
*/
|
|
510
|
+
private async resolveVctExtendsChain(
|
|
511
|
+
parentTypeMetadata: TypeMetadataFormat,
|
|
512
|
+
// We start at one, as the base is already fetched when this method is first called
|
|
513
|
+
depth: number = 1,
|
|
514
|
+
// By default include the parent vct, in case of the first call
|
|
515
|
+
visitedVcts: Set<string> = new Set(parentTypeMetadata.vct),
|
|
516
|
+
): Promise<ResolvedTypeMetadata> {
|
|
517
|
+
const maxDepth = this.userConfig.maxVctExtendsDepth ?? 5;
|
|
518
|
+
|
|
519
|
+
// Check max depth (security consideration from spec section 10.3)
|
|
520
|
+
if (maxDepth !== -1 && depth > maxDepth) {
|
|
521
|
+
throw new SDJWTException(
|
|
522
|
+
`Maximum VCT extends depth of ${maxDepth} exceeded`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!parentTypeMetadata.extends) {
|
|
527
|
+
throw new SDJWTException(
|
|
528
|
+
`Type metadata for vct '${parentTypeMetadata.vct}' has no 'extends' field. Unable to resolve extended type metadata document.`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Check for circular dependencies (security consideration from spec section 10.3)
|
|
533
|
+
if (visitedVcts.has(parentTypeMetadata.extends)) {
|
|
534
|
+
throw new SDJWTException(
|
|
535
|
+
`Circular dependency detected in VCT extends chain: ${parentTypeMetadata.extends}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Mark this VCT as visited
|
|
540
|
+
visitedVcts.add(parentTypeMetadata.extends);
|
|
541
|
+
|
|
542
|
+
const extendedTypeMetadata = await this.fetchSingleVct(
|
|
543
|
+
parentTypeMetadata.extends,
|
|
544
|
+
parentTypeMetadata['extends#integrity'],
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// While top-level vct MAY return null (meaning there's no vct type metadata)
|
|
548
|
+
// The extends value ALWAYS must resolve to a value. A custom user provided resolver
|
|
549
|
+
// can return a minimal on-demand type metadata document if it wants to support this use case
|
|
550
|
+
if (!extendedTypeMetadata) {
|
|
551
|
+
throw new SDJWTException(
|
|
552
|
+
`Resolving VCT extends value '${parentTypeMetadata.extends}' resulted in an undefined result.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let resolvedTypeMetadata: ResolvedTypeMetadata;
|
|
557
|
+
|
|
558
|
+
// If this type extends another, recursively resolve the chain
|
|
559
|
+
// We MUST first process the lower level document before processing
|
|
560
|
+
// the higher level document
|
|
561
|
+
if (extendedTypeMetadata.extends) {
|
|
562
|
+
resolvedTypeMetadata = await this.resolveVctExtendsChain(
|
|
563
|
+
extendedTypeMetadata,
|
|
564
|
+
depth + 1,
|
|
565
|
+
visitedVcts,
|
|
566
|
+
);
|
|
567
|
+
} else {
|
|
568
|
+
resolvedTypeMetadata = {
|
|
569
|
+
mergedTypeMetadata: extendedTypeMetadata,
|
|
570
|
+
typeMetadataChain: [extendedTypeMetadata],
|
|
571
|
+
vctValues: [extendedTypeMetadata.vct],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const mergedTypeMetadata = this.mergeTypeMetadata(
|
|
576
|
+
resolvedTypeMetadata.mergedTypeMetadata,
|
|
577
|
+
parentTypeMetadata,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
mergedTypeMetadata: mergedTypeMetadata,
|
|
582
|
+
typeMetadataChain: [
|
|
583
|
+
parentTypeMetadata,
|
|
584
|
+
...resolvedTypeMetadata.typeMetadataChain,
|
|
585
|
+
],
|
|
586
|
+
vctValues: [parentTypeMetadata.vct, ...resolvedTypeMetadata.vctValues],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Fetches and verifies the VCT Metadata for a VCT value.
|
|
592
|
+
* @param result
|
|
593
|
+
* @returns
|
|
594
|
+
*/
|
|
595
|
+
private async fetchSingleVct(
|
|
596
|
+
vct: string,
|
|
597
|
+
integrity?: string,
|
|
598
|
+
): Promise<TypeMetadataFormat | undefined> {
|
|
599
|
+
const fetcher =
|
|
600
|
+
this.userConfig.vctFetcher ??
|
|
601
|
+
((uri, integrity) => this.fetchWithIntegrity(uri, integrity));
|
|
602
|
+
|
|
603
|
+
// Data may be undefined
|
|
604
|
+
const data = await fetcher(vct, integrity);
|
|
605
|
+
if (!data) return undefined;
|
|
606
|
+
|
|
607
|
+
const validated = TypeMetadataFormatSchema.safeParse(data);
|
|
608
|
+
if (!validated.success) {
|
|
609
|
+
throw new SDJWTException(
|
|
610
|
+
`Invalid VCT type metadata for vct '${vct}':\n${z.prettifyError(validated.error)}`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return validated.data;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Verifies the status of the SD-JWT-VC.
|
|
619
|
+
* @param result
|
|
620
|
+
* @param options
|
|
621
|
+
*/
|
|
622
|
+
private async verifyStatus(
|
|
623
|
+
result: VerificationResult,
|
|
624
|
+
options?: VerifierOptions,
|
|
625
|
+
): Promise<void> {
|
|
626
|
+
if (options?.disableStatusVerification) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (result.payload.status) {
|
|
631
|
+
//checks if a status field is present in the payload based on https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html
|
|
632
|
+
if (result.payload.status.status_list) {
|
|
633
|
+
// fetch the status list from the uri
|
|
634
|
+
const fetcher: StatusListFetcher =
|
|
635
|
+
this.userConfig.statusListFetcher ??
|
|
636
|
+
this.statusListFetcher.bind(this);
|
|
637
|
+
// fetch the status list from the uri
|
|
638
|
+
const statusListJWT = await fetcher(
|
|
639
|
+
result.payload.status.status_list.uri,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const slJWT = Jwt.fromEncode<
|
|
643
|
+
StatusListJWTHeaderParameters,
|
|
644
|
+
StatusListJWTPayload
|
|
645
|
+
>(statusListJWT);
|
|
646
|
+
// check if the status list has a valid signature. The presence of the verifier is checked in the parent class.
|
|
647
|
+
if (!this.userConfig.verifier || !this.userConfig.statusVerifier) {
|
|
648
|
+
throw new SDJWTException('Verifier not found for status list JWT');
|
|
649
|
+
}
|
|
650
|
+
await slJWT
|
|
651
|
+
.verify(
|
|
652
|
+
this.userConfig.statusVerifier ?? this.userConfig.verifier,
|
|
653
|
+
options,
|
|
654
|
+
)
|
|
655
|
+
.catch((err: SLException) => {
|
|
656
|
+
throw new SLException(
|
|
657
|
+
`Status List JWT verification failed: ${err.message}`,
|
|
658
|
+
err.details,
|
|
659
|
+
);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// get the status list from the status list JWT
|
|
663
|
+
const statusList = getListFromStatusListJWT(statusListJWT);
|
|
664
|
+
const status = statusList.getStatus(
|
|
665
|
+
result.payload.status.status_list.idx,
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// validate the status
|
|
669
|
+
const statusValidator: StatusValidator =
|
|
670
|
+
this.userConfig.statusValidator ?? this.statusValidator.bind(this);
|
|
671
|
+
await statusValidator(status);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
public config(newConfig: SDJWTVCConfig) {
|
|
677
|
+
super.config(newConfig);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SdJwtPayload } from '@sd-jwt/core';
|
|
2
|
+
import type { SDJWTVCStatusReference } from './sd-jwt-vc-status-reference';
|
|
3
|
+
|
|
4
|
+
export interface SdJwtVcPayload extends SdJwtPayload {
|
|
5
|
+
// OPTIONAL. The Issuer of the Verifiable Credential. The value is a case-sensitive string containing a StringOrURI value. See [RFC7519] for more information.
|
|
6
|
+
iss?: string;
|
|
7
|
+
// OPTIONAL. The time before which the Verifiable Credential MUST NOT be accepted before validating. See [RFC7519] for more information.
|
|
8
|
+
nbf?: number;
|
|
9
|
+
// OPTIONAL. The expiry time of the Verifiable Credential after which the Verifiable Credential is no longer valid. See [RFC7519] for more information.
|
|
10
|
+
exp?: number;
|
|
11
|
+
// OPTIONAL unless cryptographic Key Binding is to be supported, in which case it is REQUIRED. Contains the confirmation method identifying the proof of possession key as defined in [RFC7800]. It is RECOMMENDED that this contains a JWK as defined in Section 3.2 of [RFC7800]. For proof of cryptographic Key Binding, the Key Binding JWT in the presentation of the SD-JWT MUST be signed by the key identified in this claim.
|
|
12
|
+
cnf?: unknown;
|
|
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
|
+
vct: string;
|
|
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;
|
|
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
|
+
status?: SDJWTVCStatusReference;
|
|
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.
|
|
20
|
+
sub?: string;
|
|
21
|
+
// OPTIONAL. The time of issuance of the Verifiable Credential. See [RFC7519] for more information.
|
|
22
|
+
iat?: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SDJWTVCStatusReference {
|
|
2
|
+
// REQUIRED. implenentation according to https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html
|
|
3
|
+
status_list: {
|
|
4
|
+
// REQUIRED. index in the list of statuses
|
|
5
|
+
idx: number;
|
|
6
|
+
// REQUIRED. the reference to fetch the status list
|
|
7
|
+
uri: string;
|
|
8
|
+
};
|
|
9
|
+
}
|