@sd-jwt/core 0.20.1-next.4 → 0.20.1-next.6
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/dist/index.d.mts +35 -8
- package/dist/index.d.ts +35 -8
- package/dist/index.js +262 -60
- package/dist/index.mjs +257 -59
- package/package.json +3 -3
- package/src/decode/decode.ts +59 -28
- package/src/generalJSON.ts +9 -0
- package/src/index.ts +68 -6
- package/src/jwt.ts +109 -13
- package/src/kbjwt.ts +30 -10
- package/src/present/present.ts +11 -5
- package/src/sdjwt.ts +40 -10
- package/src/test/decode/decode.spec.ts +12 -17
- package/src/test/index.spec.ts +5 -38
- package/src/test/kbjwt.spec.ts +7 -17
- package/src/types/type.ts +21 -0
- package/src/utils/disclosure.ts +24 -4
- package/test/rfc9901-audit-fixes.spec.ts +252 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sd-jwt/core",
|
|
3
|
-
"version": "0.20.1-next.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.20.1-next.6+c7cf23d",
|
|
4
|
+
"description": "SD-JWT RFC 9901 implementation in TypeScript",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"esm"
|
|
59
59
|
]
|
|
60
60
|
},
|
|
61
|
-
"gitHead": "
|
|
61
|
+
"gitHead": "c7cf23dbc1b8e35532f0e287ec5d1a852195eba7"
|
|
62
62
|
}
|
package/src/decode/decode.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_SECURE_HASH_ALGORITHMS,
|
|
3
|
+
encodePathSegment,
|
|
4
|
+
type HashAlgorithm,
|
|
2
5
|
type Hasher,
|
|
3
6
|
type HasherAndAlg,
|
|
4
7
|
type HasherAndAlgSync,
|
|
@@ -39,12 +42,7 @@ export const splitSdJwt = (
|
|
|
39
42
|
): { jwt: string; disclosures: string[]; kbJwt?: string } => {
|
|
40
43
|
const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR);
|
|
41
44
|
if (encodedDisclosures.length === 0) {
|
|
42
|
-
|
|
43
|
-
// This is for compatibility with jwt
|
|
44
|
-
return {
|
|
45
|
-
jwt: encodedJwt,
|
|
46
|
-
disclosures: [],
|
|
47
|
-
};
|
|
45
|
+
throw new SDJWTException('Invalid SD-JWT: missing SD-JWT separator');
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
const encodedKeyBindingJwt = encodedDisclosures.pop();
|
|
@@ -65,12 +63,7 @@ export const decodeSdJwt = async (
|
|
|
65
63
|
const jwt = decodeJwt(encodedJwt);
|
|
66
64
|
|
|
67
65
|
if (encodedDisclosures.length === 0) {
|
|
68
|
-
|
|
69
|
-
// This is for compatibility with jwt
|
|
70
|
-
return {
|
|
71
|
-
jwt,
|
|
72
|
-
disclosures: [],
|
|
73
|
-
};
|
|
66
|
+
throw new SDJWTException('Invalid SD-JWT: missing SD-JWT separator');
|
|
74
67
|
}
|
|
75
68
|
|
|
76
69
|
const encodedKeyBindingJwt = encodedDisclosures.pop();
|
|
@@ -101,12 +94,7 @@ export const decodeSdJwtSync = (
|
|
|
101
94
|
const jwt = decodeJwt(encodedJwt);
|
|
102
95
|
|
|
103
96
|
if (encodedDisclosures.length === 0) {
|
|
104
|
-
|
|
105
|
-
// This is for compatibility with jwt
|
|
106
|
-
return {
|
|
107
|
-
jwt,
|
|
108
|
-
disclosures: [],
|
|
109
|
-
};
|
|
97
|
+
throw new SDJWTException('Invalid SD-JWT: missing SD-JWT separator');
|
|
110
98
|
}
|
|
111
99
|
|
|
112
100
|
const encodedKeyBindingJwt = encodedDisclosures.pop();
|
|
@@ -163,7 +151,10 @@ const unpackArray = (
|
|
|
163
151
|
arr.forEach((item, idx) => {
|
|
164
152
|
if (isRecord(item)) {
|
|
165
153
|
const hash = item[SD_LIST_KEY];
|
|
166
|
-
if (
|
|
154
|
+
if (SD_LIST_KEY in item) {
|
|
155
|
+
if (Object.keys(item).length !== 1 || typeof hash !== 'string') {
|
|
156
|
+
throw new SDJWTException('Invalid array disclosure placeholder');
|
|
157
|
+
}
|
|
167
158
|
// RFC 9901 Section 7.1 step 4: reject duplicate digests
|
|
168
159
|
if (seenDigests) {
|
|
169
160
|
if (seenDigests.has(hash)) {
|
|
@@ -175,6 +166,11 @@ const unpackArray = (
|
|
|
175
166
|
}
|
|
176
167
|
const disclosed = map[hash];
|
|
177
168
|
if (disclosed) {
|
|
169
|
+
if (typeof disclosed.key === 'string') {
|
|
170
|
+
throw new SDJWTException(
|
|
171
|
+
'Object-property disclosure cannot be used as an array element',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
178
174
|
const presentKey = prefix ? `${prefix}.${idx}` : `${idx}`;
|
|
179
175
|
keys[presentKey] = hash;
|
|
180
176
|
|
|
@@ -232,12 +228,16 @@ const unpackObjInternal = (
|
|
|
232
228
|
|
|
233
229
|
const record = obj as Record<string, unknown>;
|
|
234
230
|
for (const key in record) {
|
|
231
|
+
if (prefix && key === '_sd_alg') {
|
|
232
|
+
throw new SDJWTException('Nested _sd_alg is not allowed');
|
|
233
|
+
}
|
|
235
234
|
if (
|
|
236
235
|
key !== SD_DIGEST &&
|
|
237
236
|
key !== SD_LIST_KEY &&
|
|
238
237
|
typeof record[key] === 'object'
|
|
239
238
|
) {
|
|
240
|
-
const
|
|
239
|
+
const escapedKey = encodePathSegment(key);
|
|
240
|
+
const newKey = prefix ? `${prefix}.${escapedKey}` : escapedKey;
|
|
241
241
|
const { unpackedObj, disclosureKeymap: disclosureKeys } =
|
|
242
242
|
unpackObjInternal(record[key], map, newKey, seenDigests);
|
|
243
243
|
record[key] = unpackedObj;
|
|
@@ -249,7 +249,15 @@ const unpackObjInternal = (
|
|
|
249
249
|
_sd?: Array<string>;
|
|
250
250
|
};
|
|
251
251
|
const claims: Record<string, unknown> = {};
|
|
252
|
-
if (_sd) {
|
|
252
|
+
if (_sd !== undefined) {
|
|
253
|
+
if (
|
|
254
|
+
!Array.isArray(_sd) ||
|
|
255
|
+
!_sd.every((hash) => typeof hash === 'string')
|
|
256
|
+
) {
|
|
257
|
+
throw new SDJWTException(
|
|
258
|
+
'Invalid _sd claim: expected array of strings',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
253
261
|
for (const hash of _sd) {
|
|
254
262
|
// RFC 9901 Section 7.1 step 4: reject duplicate digests
|
|
255
263
|
if (seenDigests) {
|
|
@@ -261,17 +269,26 @@ const unpackObjInternal = (
|
|
|
261
269
|
seenDigests.add(hash);
|
|
262
270
|
}
|
|
263
271
|
const disclosed = map[hash];
|
|
264
|
-
if (disclosed
|
|
272
|
+
if (disclosed) {
|
|
273
|
+
if (typeof disclosed.key !== 'string') {
|
|
274
|
+
throw new SDJWTException(
|
|
275
|
+
'Array disclosure cannot be used as an object property',
|
|
276
|
+
);
|
|
277
|
+
}
|
|
265
278
|
// RFC 9901 Section 7.1 step 3c.ii.3: reject if claim name already exists
|
|
266
279
|
if (disclosed.key in payload) {
|
|
267
280
|
throw new SDJWTException(
|
|
268
281
|
`Disclosed claim name "${disclosed.key}" conflicts with existing payload key`,
|
|
269
282
|
);
|
|
270
283
|
}
|
|
284
|
+
if (disclosed.key in claims) {
|
|
285
|
+
throw new SDJWTException(
|
|
286
|
+
`Disclosed claim name "${disclosed.key}" conflicts with another disclosure`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
271
289
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
: disclosed.key;
|
|
290
|
+
const escapedKey = encodePathSegment(disclosed.key);
|
|
291
|
+
const presentKey = prefix ? `${prefix}.${escapedKey}` : escapedKey;
|
|
275
292
|
keys[presentKey] = hash;
|
|
276
293
|
|
|
277
294
|
const { unpackedObj, disclosureKeymap: disclosureKeys } =
|
|
@@ -297,6 +314,9 @@ export const createHashMapping = async (
|
|
|
297
314
|
for (let i = 0; i < disclosures.length; i++) {
|
|
298
315
|
const disclosure = disclosures[i];
|
|
299
316
|
const digest = await disclosure.digest(hash);
|
|
317
|
+
if (digest in map) {
|
|
318
|
+
throw new SDJWTException('Duplicate disclosure digest detected');
|
|
319
|
+
}
|
|
300
320
|
map[digest] = disclosure;
|
|
301
321
|
}
|
|
302
322
|
return map;
|
|
@@ -310,18 +330,26 @@ export const createHashMappingSync = (
|
|
|
310
330
|
for (let i = 0; i < disclosures.length; i++) {
|
|
311
331
|
const disclosure = disclosures[i];
|
|
312
332
|
const digest = disclosure.digestSync(hash);
|
|
333
|
+
if (digest in map) {
|
|
334
|
+
throw new SDJWTException('Duplicate disclosure digest detected');
|
|
335
|
+
}
|
|
313
336
|
map[digest] = disclosure;
|
|
314
337
|
}
|
|
315
338
|
return map;
|
|
316
339
|
};
|
|
317
340
|
|
|
318
341
|
// Extract _sd_alg. If it is not present, it is assumed to be sha-256
|
|
319
|
-
export const getSDAlgAndPayload = (
|
|
342
|
+
export const getSDAlgAndPayload = (
|
|
343
|
+
SdJwtPayload: Record<string, unknown>,
|
|
344
|
+
allowedAlgorithms: ReadonlyArray<HashAlgorithm> = DEFAULT_SECURE_HASH_ALGORITHMS,
|
|
345
|
+
) => {
|
|
320
346
|
const { _sd_alg, ...payload } = SdJwtPayload;
|
|
321
|
-
if (
|
|
322
|
-
// This is for compatibility
|
|
347
|
+
if (_sd_alg === undefined) {
|
|
323
348
|
return { _sd_alg: 'sha-256', payload };
|
|
324
349
|
}
|
|
350
|
+
if (typeof _sd_alg !== 'string') {
|
|
351
|
+
throw new SDJWTException('Invalid _sd_alg: expected string');
|
|
352
|
+
}
|
|
325
353
|
if (
|
|
326
354
|
!IANA_HASH_ALGORITHMS.includes(
|
|
327
355
|
_sd_alg as (typeof IANA_HASH_ALGORITHMS)[number],
|
|
@@ -329,6 +357,9 @@ export const getSDAlgAndPayload = (SdJwtPayload: Record<string, unknown>) => {
|
|
|
329
357
|
) {
|
|
330
358
|
throw new SDJWTException(`Invalid _sd_alg: ${_sd_alg}`);
|
|
331
359
|
}
|
|
360
|
+
if (!allowedAlgorithms.includes(_sd_alg as HashAlgorithm)) {
|
|
361
|
+
throw new SDJWTException(`Disallowed _sd_alg: ${_sd_alg}`);
|
|
362
|
+
}
|
|
332
363
|
return { _sd_alg, payload };
|
|
333
364
|
};
|
|
334
365
|
|
package/src/generalJSON.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type GeneralJSONSerialized = {
|
|
|
20
20
|
disclosures?: Array<string>;
|
|
21
21
|
kid?: string;
|
|
22
22
|
kb_jwt?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
23
24
|
};
|
|
24
25
|
protected: string;
|
|
25
26
|
signature: string;
|
|
@@ -68,6 +69,14 @@ export class GeneralJSON {
|
|
|
68
69
|
if (!json.signatures[0]) {
|
|
69
70
|
throw new SDJWTException('Invalid JSON');
|
|
70
71
|
}
|
|
72
|
+
for (let index = 1; index < json.signatures.length; index++) {
|
|
73
|
+
const header = json.signatures[index].header;
|
|
74
|
+
if (header && ('disclosures' in header || 'kb_jwt' in header)) {
|
|
75
|
+
throw new SDJWTException(
|
|
76
|
+
'disclosures and kb_jwt MUST only appear in the first unprotected header',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
71
80
|
const disclosures = json.signatures[0].header?.disclosures ?? [];
|
|
72
81
|
const kb_jwt = json.signatures[0].header?.kb_jwt;
|
|
73
82
|
return new GeneralJSON({
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getSDAlgAndPayload } from './decode';
|
|
2
2
|
import { FlattenJSON } from './flattenJSON';
|
|
3
3
|
import { GeneralJSON } from './generalJSON';
|
|
4
|
-
import { Jwt, type VerifierOptions } from './jwt';
|
|
4
|
+
import { Jwt, type VerifierOptions, validateJwtPayload } from './jwt';
|
|
5
5
|
import { KBJwt } from './kbjwt';
|
|
6
6
|
import { pack, SDJwt } from './sdjwt';
|
|
7
7
|
import {
|
|
8
|
+
DEFAULT_SECURE_HASH_ALGORITHMS,
|
|
8
9
|
type DisclosureFrame,
|
|
9
10
|
type Hasher,
|
|
10
11
|
IANA_HASH_ALGORITHMS,
|
|
@@ -87,6 +88,17 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
|
|
|
87
88
|
`Invalid hash algorithm: ${userConfig.hashAlg}`,
|
|
88
89
|
);
|
|
89
90
|
}
|
|
91
|
+
const allowedDisclosureHashAlgorithms =
|
|
92
|
+
userConfig.allowedDisclosureHashAlgorithms ??
|
|
93
|
+
DEFAULT_SECURE_HASH_ALGORITHMS;
|
|
94
|
+
if (
|
|
95
|
+
userConfig.hashAlg &&
|
|
96
|
+
!allowedDisclosureHashAlgorithms.includes(userConfig.hashAlg)
|
|
97
|
+
) {
|
|
98
|
+
throw new SDJWTException(
|
|
99
|
+
`Disallowed hash algorithm: ${userConfig.hashAlg}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
90
102
|
this.userConfig = userConfig;
|
|
91
103
|
}
|
|
92
104
|
}
|
|
@@ -148,12 +160,21 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
|
|
|
148
160
|
if (!this.userConfig.signAlg) {
|
|
149
161
|
throw new SDJWTException('sign alogrithm not specified');
|
|
150
162
|
}
|
|
163
|
+
if (this.userConfig.signAlg === 'none') {
|
|
164
|
+
throw new SDJWTException('sign algorithm "none" is not allowed');
|
|
165
|
+
}
|
|
151
166
|
|
|
152
167
|
this.validateReservedFields<Payload>(payload);
|
|
153
168
|
this.validateDisclosureFrame<Payload>(disclosureFrame);
|
|
154
169
|
|
|
155
170
|
const hasher = this.userConfig.hasher;
|
|
156
171
|
const hashAlg = this.userConfig.hashAlg ?? SDJwtInstance.DEFAULT_hashAlg;
|
|
172
|
+
const allowedDisclosureHashAlgorithms =
|
|
173
|
+
this.userConfig.allowedDisclosureHashAlgorithms ??
|
|
174
|
+
DEFAULT_SECURE_HASH_ALGORITHMS;
|
|
175
|
+
if (!allowedDisclosureHashAlgorithms.includes(hashAlg)) {
|
|
176
|
+
throw new SDJWTException(`Disallowed hash algorithm: ${hashAlg}`);
|
|
177
|
+
}
|
|
157
178
|
|
|
158
179
|
const { packedClaims, disclosures } = await pack(
|
|
159
180
|
payload,
|
|
@@ -212,6 +233,9 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
|
|
|
212
233
|
const hasher = this.userConfig.hasher;
|
|
213
234
|
|
|
214
235
|
const sdjwt = await SDJwt.fromEncode(encodedSDJwt, hasher);
|
|
236
|
+
if (sdjwt.kbJwt) {
|
|
237
|
+
throw new SDJWTException('Holder cannot present an SD-JWT with KB-JWT');
|
|
238
|
+
}
|
|
215
239
|
|
|
216
240
|
if (!sdjwt.jwt?.payload) throw new SDJWTException('Payload not found');
|
|
217
241
|
const presentSdJwtWithoutKb = await sdjwt.present(
|
|
@@ -394,10 +418,14 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
|
|
|
394
418
|
// Validate signature and claims
|
|
395
419
|
if (sdjwt?.jwt) {
|
|
396
420
|
try {
|
|
397
|
-
const result = await this.VerifyJwt(sdjwt.jwt,
|
|
421
|
+
const result = await this.VerifyJwt(sdjwt.jwt, {
|
|
422
|
+
...options,
|
|
423
|
+
skipJwtClaimValidation: true,
|
|
424
|
+
} as T & VerifierOptions);
|
|
398
425
|
header = result.header;
|
|
399
426
|
const claims = await sdjwt.getClaims(hasher);
|
|
400
427
|
payload = claims as ExtendedPayload;
|
|
428
|
+
validateJwtPayload(payload, options);
|
|
401
429
|
} catch (e) {
|
|
402
430
|
const error = ensureError(e);
|
|
403
431
|
const code = exceptionToCode(error);
|
|
@@ -538,10 +566,14 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
|
|
|
538
566
|
throw new SDJWTException('Invalid SD JWT');
|
|
539
567
|
}
|
|
540
568
|
|
|
541
|
-
const verifiedPayloads = await this.VerifyJwt(sdjwt.jwt,
|
|
569
|
+
const verifiedPayloads = await this.VerifyJwt(sdjwt.jwt, {
|
|
570
|
+
...options,
|
|
571
|
+
skipJwtClaimValidation: true,
|
|
572
|
+
} as T & VerifierOptions);
|
|
542
573
|
const claims = await sdjwt.getClaims<ExtendedPayload>(hasher);
|
|
543
574
|
// Validate that unpacked claims do not contain reserved field names
|
|
544
575
|
validateReservedFieldsInternal(claims);
|
|
576
|
+
validateJwtPayload(claims, options);
|
|
545
577
|
return { payload: claims, header: verifiedPayloads.header };
|
|
546
578
|
}
|
|
547
579
|
|
|
@@ -611,6 +643,17 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
611
643
|
`Invalid hash algorithm: ${userConfig.hashAlg}`,
|
|
612
644
|
);
|
|
613
645
|
}
|
|
646
|
+
const allowedDisclosureHashAlgorithms =
|
|
647
|
+
userConfig.allowedDisclosureHashAlgorithms ??
|
|
648
|
+
DEFAULT_SECURE_HASH_ALGORITHMS;
|
|
649
|
+
if (
|
|
650
|
+
userConfig.hashAlg &&
|
|
651
|
+
!allowedDisclosureHashAlgorithms.includes(userConfig.hashAlg)
|
|
652
|
+
) {
|
|
653
|
+
throw new SDJWTException(
|
|
654
|
+
`Disallowed hash algorithm: ${userConfig.hashAlg}`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
614
657
|
this.userConfig = userConfig;
|
|
615
658
|
}
|
|
616
659
|
}
|
|
@@ -738,6 +781,9 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
738
781
|
const hasher = this.userConfig.hasher;
|
|
739
782
|
const encodedSDJwt = generalJSON.toEncoded(0);
|
|
740
783
|
const sdjwt = await SDJwt.fromEncode(encodedSDJwt, hasher);
|
|
784
|
+
if (sdjwt.kbJwt) {
|
|
785
|
+
throw new SDJWTException('Holder cannot present an SD-JWT with KB-JWT');
|
|
786
|
+
}
|
|
741
787
|
|
|
742
788
|
if (!sdjwt.jwt?.payload) throw new SDJWTException('Payload not found');
|
|
743
789
|
const disclosures = await sdjwt.getPresentDisclosures(
|
|
@@ -781,7 +827,7 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
781
827
|
}
|
|
782
828
|
const hasher = this.userConfig.hasher;
|
|
783
829
|
|
|
784
|
-
const { payload, headers } = await this.validate(generalJSON);
|
|
830
|
+
const { payload, headers } = await this.validate(generalJSON, options);
|
|
785
831
|
|
|
786
832
|
const encodedSDJwt = generalJSON.toEncoded(0);
|
|
787
833
|
const sdjwt = await SDJwt.fromEncode(encodedSDJwt, hasher);
|
|
@@ -856,7 +902,7 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
856
902
|
|
|
857
903
|
// This function is for validating the SD JWT
|
|
858
904
|
// Just checking signature and return its the claims
|
|
859
|
-
public async validate(generalJSON: GeneralJSON) {
|
|
905
|
+
public async validate(generalJSON: GeneralJSON, options?: VerifierOptions) {
|
|
860
906
|
if (!this.userConfig.hasher) {
|
|
861
907
|
throw new SDJWTException('Hasher not found');
|
|
862
908
|
}
|
|
@@ -874,8 +920,23 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
874
920
|
const verified = await verifier(
|
|
875
921
|
`${encodedHeader}.${payload}`,
|
|
876
922
|
signature,
|
|
923
|
+
{ ...options, skipJwtClaimValidation: true },
|
|
924
|
+
);
|
|
925
|
+
const header = decodeBase64urlJsonStrict<Record<string, unknown>>(
|
|
926
|
+
encodedHeader,
|
|
927
|
+
'Invalid JWT',
|
|
877
928
|
);
|
|
878
|
-
|
|
929
|
+
if (typeof header.alg !== 'string' || header.alg === 'none') {
|
|
930
|
+
throw new SDJWTException('Verify Error: alg "none" is not allowed');
|
|
931
|
+
}
|
|
932
|
+
if (
|
|
933
|
+
options?.allowedIssuerAlgorithms &&
|
|
934
|
+
!options.allowedIssuerAlgorithms.includes(header.alg)
|
|
935
|
+
) {
|
|
936
|
+
throw new SDJWTException(
|
|
937
|
+
`Verify Error: Disallowed alg ${header.alg}`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
879
940
|
return { verified, header };
|
|
880
941
|
}),
|
|
881
942
|
);
|
|
@@ -894,6 +955,7 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
|
|
|
894
955
|
const claims = await sdjwt.getClaims<ExtendedPayload>(hasher);
|
|
895
956
|
// Validate that unpacked claims do not contain reserved field names
|
|
896
957
|
validateReservedFieldsInternal(claims);
|
|
958
|
+
validateJwtPayload(claims, options);
|
|
897
959
|
return { payload: claims, headers: results.map((r) => r.header) };
|
|
898
960
|
}
|
|
899
961
|
|
package/src/jwt.ts
CHANGED
|
@@ -32,11 +32,37 @@ export type VerifierOptions = {
|
|
|
32
32
|
*/
|
|
33
33
|
requiredClaimKeys?: string[];
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Expected audience for the processed SD-JWT payload.
|
|
37
|
+
*/
|
|
38
|
+
expectedAudience?: string | string[];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Allowed JOSE algorithms for issuer-signed JWTs. `none` is always rejected.
|
|
42
|
+
*/
|
|
43
|
+
allowedIssuerAlgorithms?: string[];
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* nonce used to verify the key binding jwt to prevent replay attacks.
|
|
37
47
|
*/
|
|
38
48
|
keyBindingNonce?: string;
|
|
39
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Expected audience for the Key Binding JWT.
|
|
52
|
+
*/
|
|
53
|
+
expectedKeyBindingAudience?: string | string[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Maximum acceptable age of the Key Binding JWT, in seconds.
|
|
57
|
+
*/
|
|
58
|
+
keyBindingMaxAgeSeconds?: number;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Internal option used by SD-JWT validation to defer claim checks until after
|
|
62
|
+
* disclosures are processed.
|
|
63
|
+
*/
|
|
64
|
+
skipJwtClaimValidation?: boolean;
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* disable the verification of the status claim in the payload.
|
|
42
68
|
*
|
|
@@ -50,6 +76,75 @@ export type VerifierOptions = {
|
|
|
50
76
|
[key: string]: unknown;
|
|
51
77
|
};
|
|
52
78
|
|
|
79
|
+
const isStringArray = (value: unknown): value is string[] =>
|
|
80
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
81
|
+
|
|
82
|
+
const validateNumericDate = (
|
|
83
|
+
payload: Record<string, unknown>,
|
|
84
|
+
claim: 'iat' | 'nbf' | 'exp',
|
|
85
|
+
) => {
|
|
86
|
+
const value = payload[claim];
|
|
87
|
+
if (value === undefined) return undefined;
|
|
88
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
89
|
+
throw new SDJWTException(`Verify Error: JWT ${claim} must be a number`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const getAudiences = (audience: unknown): string[] | undefined => {
|
|
95
|
+
if (typeof audience === 'string') return [audience];
|
|
96
|
+
if (isStringArray(audience)) return audience;
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const validateAudience = (
|
|
101
|
+
payload: Record<string, unknown>,
|
|
102
|
+
expectedAudience: string | string[] | undefined,
|
|
103
|
+
) => {
|
|
104
|
+
if (expectedAudience === undefined) return;
|
|
105
|
+
|
|
106
|
+
const expectedAudiences = Array.isArray(expectedAudience)
|
|
107
|
+
? expectedAudience
|
|
108
|
+
: [expectedAudience];
|
|
109
|
+
const audiences = getAudiences(payload.aud);
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
!audiences ||
|
|
113
|
+
!expectedAudiences.some((expected) => audiences.includes(expected))
|
|
114
|
+
) {
|
|
115
|
+
throw new SDJWTException('Verify Error: Invalid audience');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const validateJwtPayload = (
|
|
120
|
+
payload: Record<string, unknown> | undefined,
|
|
121
|
+
options?: VerifierOptions,
|
|
122
|
+
) => {
|
|
123
|
+
if (!payload) {
|
|
124
|
+
throw new SDJWTException('Verify Error: JWT payload is missing');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const skew = options?.skewSeconds ? options.skewSeconds : 0;
|
|
128
|
+
const currentDate = options?.currentDate ?? Math.floor(Date.now() / 1000);
|
|
129
|
+
const iat = validateNumericDate(payload, 'iat');
|
|
130
|
+
const nbf = validateNumericDate(payload, 'nbf');
|
|
131
|
+
const exp = validateNumericDate(payload, 'exp');
|
|
132
|
+
|
|
133
|
+
if (iat !== undefined && iat - skew > currentDate) {
|
|
134
|
+
throw new SDJWTException('Verify Error: JWT is not yet valid');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (nbf !== undefined && nbf - skew > currentDate) {
|
|
138
|
+
throw new SDJWTException('Verify Error: JWT is not yet valid');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (exp !== undefined && exp + skew <= currentDate) {
|
|
142
|
+
throw new SDJWTException('Verify Error: JWT is expired');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
validateAudience(payload, options?.expectedAudience);
|
|
146
|
+
};
|
|
147
|
+
|
|
53
148
|
// This class is used to create and verify JWT
|
|
54
149
|
// Contains header, payload, and signature
|
|
55
150
|
export class Jwt<
|
|
@@ -127,6 +222,9 @@ export class Jwt<
|
|
|
127
222
|
}
|
|
128
223
|
|
|
129
224
|
public async sign(signer: Signer) {
|
|
225
|
+
if (!this.header || this.header.alg === 'none') {
|
|
226
|
+
throw new SDJWTException('Sign Error: alg "none" is not allowed');
|
|
227
|
+
}
|
|
130
228
|
const data = this.getUnsignedToken();
|
|
131
229
|
this.signature = await signer(data);
|
|
132
230
|
|
|
@@ -159,21 +257,19 @@ export class Jwt<
|
|
|
159
257
|
* @returns
|
|
160
258
|
*/
|
|
161
259
|
public async verify<T>(verifier: Verifier<T>, options?: T & VerifierOptions) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const nbf = this.payload?.nbf;
|
|
166
|
-
const exp = this.payload?.exp;
|
|
167
|
-
|
|
168
|
-
if (typeof iat === 'number' && iat - skew > currentDate) {
|
|
169
|
-
throw new SDJWTException('Verify Error: JWT is not yet valid');
|
|
260
|
+
const alg = this.header?.alg;
|
|
261
|
+
if (typeof alg !== 'string' || alg === 'none') {
|
|
262
|
+
throw new SDJWTException('Verify Error: alg "none" is not allowed');
|
|
170
263
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
264
|
+
if (
|
|
265
|
+
options?.allowedIssuerAlgorithms &&
|
|
266
|
+
!options.allowedIssuerAlgorithms.includes(alg)
|
|
267
|
+
) {
|
|
268
|
+
throw new SDJWTException(`Verify Error: Disallowed alg ${alg}`);
|
|
174
269
|
}
|
|
175
|
-
|
|
176
|
-
|
|
270
|
+
|
|
271
|
+
if (!options?.skipJwtClaimValidation) {
|
|
272
|
+
validateJwtPayload(this.payload, options);
|
|
177
273
|
}
|
|
178
274
|
|
|
179
275
|
if (!this.signature) {
|
package/src/kbjwt.ts
CHANGED
|
@@ -28,18 +28,15 @@ export class KBJwt<
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if (
|
|
31
|
-
|
|
31
|
+
typeof this.header.alg !== 'string' ||
|
|
32
32
|
this.header.alg === 'none' ||
|
|
33
|
-
|
|
33
|
+
typeof this.header.typ !== 'string' ||
|
|
34
34
|
this.header.typ !== KB_JWT_TYP ||
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.payload.sd_hash ||
|
|
41
|
-
('_sd_hash' in this.payload && this.payload._sd_hash)
|
|
42
|
-
)
|
|
35
|
+
typeof this.payload.iat !== 'number' ||
|
|
36
|
+
typeof this.payload.aud !== 'string' ||
|
|
37
|
+
typeof this.payload.nonce !== 'string' ||
|
|
38
|
+
typeof this.payload.sd_hash !== 'string' ||
|
|
39
|
+
this.payload.sd_hash.length === 0
|
|
43
40
|
) {
|
|
44
41
|
throw new SDJWTException('Invalid Key Binding Jwt');
|
|
45
42
|
}
|
|
@@ -48,6 +45,29 @@ export class KBJwt<
|
|
|
48
45
|
throw new SDJWTException('Verify Error: Invalid Nonce');
|
|
49
46
|
}
|
|
50
47
|
|
|
48
|
+
if (values.options?.expectedKeyBindingAudience !== undefined) {
|
|
49
|
+
const expectedAudiences = Array.isArray(
|
|
50
|
+
values.options.expectedKeyBindingAudience,
|
|
51
|
+
)
|
|
52
|
+
? values.options.expectedKeyBindingAudience
|
|
53
|
+
: [values.options.expectedKeyBindingAudience];
|
|
54
|
+
if (!expectedAudiences.includes(this.payload.aud)) {
|
|
55
|
+
throw new SDJWTException('Verify Error: Invalid Key Binding audience');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (values.options?.keyBindingMaxAgeSeconds !== undefined) {
|
|
60
|
+
const currentDate =
|
|
61
|
+
values.options.currentDate ?? Math.floor(Date.now() / 1000);
|
|
62
|
+
const skew = values.options.skewSeconds ?? 0;
|
|
63
|
+
if (
|
|
64
|
+
this.payload.iat + values.options.keyBindingMaxAgeSeconds + skew <
|
|
65
|
+
currentDate
|
|
66
|
+
) {
|
|
67
|
+
throw new SDJWTException('Verify Error: Key Binding JWT is too old');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
// Delegate signature verification and common JWT claim validation
|
|
52
72
|
// (iat, nbf, exp) to the shared Jwt.verify implementation. The kbVerifier
|
|
53
73
|
// needs the kb+jwt payload (e.g. the holder's cnf key), so we wrap it to
|
package/src/present/present.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
unpackSync,
|
|
11
11
|
} from '../decode';
|
|
12
12
|
import type { Extensible, HasherSync } from '../types';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
encodePath,
|
|
15
|
+
type Hasher,
|
|
16
|
+
type PresentationFrame,
|
|
17
|
+
SD_SEPARATOR,
|
|
18
|
+
} from '../types';
|
|
14
19
|
import { Disclosure, SDJWTException } from '../utils';
|
|
15
20
|
|
|
16
21
|
// Presentable keys
|
|
@@ -117,18 +122,19 @@ export const presentSync = <T extends Record<string, unknown>>(
|
|
|
117
122
|
*/
|
|
118
123
|
export const transformPresentationFrame = (
|
|
119
124
|
obj: PresentationFrame<Extensible>,
|
|
120
|
-
prefix =
|
|
125
|
+
prefix: string[] = [],
|
|
121
126
|
): string[] => {
|
|
122
127
|
return Object.entries(obj).reduce<string[]>((acc, [key, value]) => {
|
|
123
|
-
const newPrefix = prefix
|
|
128
|
+
const newPrefix = [...prefix, key];
|
|
129
|
+
const encodedPrefix = encodePath(newPrefix);
|
|
124
130
|
if (typeof value === 'boolean') {
|
|
125
131
|
// only add it, when it's true
|
|
126
132
|
if (value) {
|
|
127
|
-
acc.push(
|
|
133
|
+
acc.push(encodedPrefix);
|
|
128
134
|
}
|
|
129
135
|
} else if (typeof value === 'object' && value !== null) {
|
|
130
136
|
acc.push(
|
|
131
|
-
|
|
137
|
+
encodedPrefix,
|
|
132
138
|
...transformPresentationFrame(
|
|
133
139
|
value as PresentationFrame<Extensible>,
|
|
134
140
|
newPrefix,
|