@sd-jwt/core 0.20.1-next.5 → 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/src/sdjwt.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { KBJwt } from './kbjwt';
|
|
|
5
5
|
import { transformPresentationFrame } from './present';
|
|
6
6
|
import {
|
|
7
7
|
type DisclosureFrame,
|
|
8
|
+
encodePathSegment,
|
|
8
9
|
type Hasher,
|
|
9
10
|
type HasherAndAlg,
|
|
10
11
|
type kbHeader,
|
|
@@ -19,6 +20,28 @@ import {
|
|
|
19
20
|
} from './types';
|
|
20
21
|
import { Disclosure, SDJWTException } from './utils';
|
|
21
22
|
|
|
23
|
+
const createDisclosureSalt = async (
|
|
24
|
+
saltGenerator: SaltGenerator,
|
|
25
|
+
seenSalts: Set<string>,
|
|
26
|
+
) => {
|
|
27
|
+
const salt = await saltGenerator(16);
|
|
28
|
+
if (typeof salt !== 'string') {
|
|
29
|
+
throw new SDJWTException('SaltGenerator must return a string');
|
|
30
|
+
}
|
|
31
|
+
if (seenSalts.has(salt)) {
|
|
32
|
+
throw new SDJWTException('Duplicate disclosure salt detected');
|
|
33
|
+
}
|
|
34
|
+
seenSalts.add(salt);
|
|
35
|
+
return salt;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const addDisclosureDigest = (digest: string, seenDigests: Set<string>) => {
|
|
39
|
+
if (seenDigests.has(digest)) {
|
|
40
|
+
throw new SDJWTException('Duplicate disclosure digest detected');
|
|
41
|
+
}
|
|
42
|
+
seenDigests.add(digest);
|
|
43
|
+
};
|
|
44
|
+
|
|
22
45
|
export type SDJwtData<
|
|
23
46
|
Header extends Record<string, unknown>,
|
|
24
47
|
Payload extends Record<string, unknown>,
|
|
@@ -60,19 +83,15 @@ export class SDJwt<
|
|
|
60
83
|
kbJwt?: KBJwt<KBHeader, KBPayload>;
|
|
61
84
|
}> {
|
|
62
85
|
const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR);
|
|
86
|
+
if (encodedDisclosures.length === 0) {
|
|
87
|
+
throw new SDJWTException('Invalid SD-JWT: missing SD-JWT separator');
|
|
88
|
+
}
|
|
63
89
|
const jwt = Jwt.fromEncode<Header, Payload>(encodedJwt);
|
|
64
90
|
|
|
65
91
|
if (!jwt.payload) {
|
|
66
92
|
throw new Error('Payload is undefined on the JWT. Invalid state reached');
|
|
67
93
|
}
|
|
68
94
|
|
|
69
|
-
if (encodedDisclosures.length === 0) {
|
|
70
|
-
return {
|
|
71
|
-
jwt,
|
|
72
|
-
disclosures: [],
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
95
|
const encodedKeyBindingJwt = encodedDisclosures.pop();
|
|
77
96
|
const kbJwt = encodedKeyBindingJwt
|
|
78
97
|
? KBJwt.fromKBEncode<KBHeader, KBPayload>(encodedKeyBindingJwt)
|
|
@@ -218,7 +237,8 @@ export const listKeys = (obj: Record<string, unknown>, prefix = '') => {
|
|
|
218
237
|
const keys: string[] = [];
|
|
219
238
|
for (const key in obj) {
|
|
220
239
|
if (obj[key] === undefined) continue;
|
|
221
|
-
const
|
|
240
|
+
const escapedKey = encodePathSegment(key);
|
|
241
|
+
const newKey = prefix ? `${prefix}.${escapedKey}` : escapedKey;
|
|
222
242
|
keys.push(newKey);
|
|
223
243
|
|
|
224
244
|
const value = obj[key];
|
|
@@ -234,6 +254,8 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
234
254
|
disclosureFrame: DisclosureFrame<T> | undefined,
|
|
235
255
|
hash: HasherAndAlg,
|
|
236
256
|
saltGenerator: SaltGenerator,
|
|
257
|
+
seenSalts = new Set<string>(),
|
|
258
|
+
seenDigests = new Set<string>(),
|
|
237
259
|
): Promise<{
|
|
238
260
|
packedClaims: Record<string, unknown> | Array<Record<string, unknown>>;
|
|
239
261
|
disclosures: Array<Disclosure>;
|
|
@@ -261,6 +283,8 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
261
283
|
disclosureFrame[idx],
|
|
262
284
|
hash,
|
|
263
285
|
saltGenerator,
|
|
286
|
+
seenSalts,
|
|
287
|
+
seenDigests,
|
|
264
288
|
);
|
|
265
289
|
recursivePackedClaims[idx] = packed.packedClaims;
|
|
266
290
|
disclosures.push(...packed.disclosures);
|
|
@@ -290,9 +314,10 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
290
314
|
*/
|
|
291
315
|
// @ts-expect-error
|
|
292
316
|
if (sd.includes(i)) {
|
|
293
|
-
const salt = await saltGenerator
|
|
317
|
+
const salt = await createDisclosureSalt(saltGenerator, seenSalts);
|
|
294
318
|
const disclosure = new Disclosure([salt, claim]);
|
|
295
319
|
const digest = await disclosure.digest(hash);
|
|
320
|
+
addDisclosureDigest(digest, seenDigests);
|
|
296
321
|
packedClaims.push({ [SD_LIST_KEY]: digest });
|
|
297
322
|
disclosures.push(disclosure);
|
|
298
323
|
} else {
|
|
@@ -301,6 +326,7 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
301
326
|
}
|
|
302
327
|
for (let j = 0; j < decoyCount; j++) {
|
|
303
328
|
const decoyDigest = await createDecoy(hash, saltGenerator);
|
|
329
|
+
addDisclosureDigest(decoyDigest, seenDigests);
|
|
304
330
|
packedClaims.push({ [SD_LIST_KEY]: decoyDigest });
|
|
305
331
|
}
|
|
306
332
|
return { packedClaims, disclosures };
|
|
@@ -318,6 +344,8 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
318
344
|
disclosureFrame[key],
|
|
319
345
|
hash,
|
|
320
346
|
saltGenerator,
|
|
347
|
+
seenSalts,
|
|
348
|
+
seenDigests,
|
|
321
349
|
);
|
|
322
350
|
recursivePackedClaims[key] = packed.packedClaims;
|
|
323
351
|
disclosures.push(...packed.disclosures);
|
|
@@ -331,9 +359,10 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
331
359
|
? recursivePackedClaims[key]
|
|
332
360
|
: claims[key];
|
|
333
361
|
if (sd.includes(key)) {
|
|
334
|
-
const salt = await saltGenerator
|
|
362
|
+
const salt = await createDisclosureSalt(saltGenerator, seenSalts);
|
|
335
363
|
const disclosure = new Disclosure([salt, key, claim]);
|
|
336
364
|
const digest = await disclosure.digest(hash);
|
|
365
|
+
addDisclosureDigest(digest, seenDigests);
|
|
337
366
|
|
|
338
367
|
_sd.push(digest);
|
|
339
368
|
disclosures.push(disclosure);
|
|
@@ -344,6 +373,7 @@ export const pack = async <T extends Record<string, unknown>>(
|
|
|
344
373
|
|
|
345
374
|
for (let j = 0; j < decoyCount; j++) {
|
|
346
375
|
const decoyDigest = await createDecoy(hash, saltGenerator);
|
|
376
|
+
addDisclosureDigest(decoyDigest, seenDigests);
|
|
347
377
|
_sd.push(decoyDigest);
|
|
348
378
|
}
|
|
349
379
|
|
|
@@ -39,12 +39,11 @@ describe('decode tests', () => {
|
|
|
39
39
|
expect(kbJwt).toBeUndefined();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test('split
|
|
42
|
+
test('split rejects bare jwt without SD-JWT separator', () => {
|
|
43
43
|
const sdjwt = 'h.p.s';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(kbJwt).toBeUndefined();
|
|
44
|
+
expect(() => splitSdJwt(sdjwt)).toThrow(
|
|
45
|
+
'Invalid SD-JWT: missing SD-JWT separator',
|
|
46
|
+
);
|
|
48
47
|
});
|
|
49
48
|
|
|
50
49
|
test('split sdjwt with kbjwt', () => {
|
|
@@ -65,14 +64,12 @@ describe('decode tests', () => {
|
|
|
65
64
|
expect(decodedSdJwt.jwt).toBeDefined();
|
|
66
65
|
});
|
|
67
66
|
|
|
68
|
-
test('decode jwt', async () => {
|
|
67
|
+
test('decode rejects bare jwt without SD-JWT separator', async () => {
|
|
69
68
|
const jwt =
|
|
70
69
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6InNkK2p3dCJ9.eyJsYXN0bmFtZSI6IkRvZSIsInNzbiI6IjEyMy00NS02Nzg5IiwiX3NkIjpbIk4yUXhZV1UxTlRnME1qQmpOR1JpWVRCaU1tRmtaamN5WXpSbFpXUmhaRGd5WkRCbE1qaGhZVGcwTnpJMU9XSXpZek5qWkdNNE1qZG1NVGN6TmpZd05RIiwiWlRSalkyUTVOemRoWkRVM05tWTFZV0UyTmpka01XVmpNRE16WXpOak5qQmtNak5pT0dZelpHSTBOelV4TURsak9EWTRNREEzWm1JeFpUY3daREZqTmciXSwiX3NkX2FsZyI6InNoYS0yNTYifQ.mX14Sw86xy8NFQta7tCfNmhVCqzfaJ_K3VEIhTjbLDY';
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
expect(decodedSdJwt.disclosures.length).toEqual(0);
|
|
75
|
-
expect(decodedSdJwt.jwt).toBeDefined();
|
|
70
|
+
await expect(decodeSdJwt(jwt, digest)).rejects.toThrow(
|
|
71
|
+
'Invalid SD-JWT: missing SD-JWT separator',
|
|
72
|
+
);
|
|
76
73
|
});
|
|
77
74
|
|
|
78
75
|
test('decode sdjwt sync', () => {
|
|
@@ -85,14 +82,12 @@ describe('decode tests', () => {
|
|
|
85
82
|
expect(decodedSdJwt.jwt).toBeDefined();
|
|
86
83
|
});
|
|
87
84
|
|
|
88
|
-
test('decode jwt
|
|
85
|
+
test('decode sync rejects bare jwt without SD-JWT separator', () => {
|
|
89
86
|
const jwt =
|
|
90
87
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6InNkK2p3dCJ9.eyJsYXN0bmFtZSI6IkRvZSIsInNzbiI6IjEyMy00NS02Nzg5IiwiX3NkIjpbIk4yUXhZV1UxTlRnME1qQmpOR1JpWVRCaU1tRmtaamN5WXpSbFpXUmhaRGd5WkRCbE1qaGhZVGcwTnpJMU9XSXpZek5qWkdNNE1qZG1NVGN6TmpZd05RIiwiWlRSalkyUTVOemRoWkRVM05tWTFZV0UyTmpka01XVmpNRE16WXpOak5qQmtNak5pT0dZelpHSTBOelV4TURsak9EWTRNREEzWm1JeFpUY3daREZqTmciXSwiX3NkX2FsZyI6InNoYS0yNTYifQ.mX14Sw86xy8NFQta7tCfNmhVCqzfaJ_K3VEIhTjbLDY';
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
expect(decodedSdJwt.disclosures.length).toEqual(0);
|
|
95
|
-
expect(decodedSdJwt.jwt).toBeDefined();
|
|
88
|
+
expect(() => decodeSdJwtSync(jwt, digest)).toThrow(
|
|
89
|
+
'Invalid SD-JWT: missing SD-JWT separator',
|
|
90
|
+
);
|
|
96
91
|
});
|
|
97
92
|
|
|
98
93
|
test('decode sdjwt sync (with KB)', () => {
|
package/src/test/index.spec.ts
CHANGED
|
@@ -130,44 +130,11 @@ describe('index', () => {
|
|
|
130
130
|
test.each([
|
|
131
131
|
'_sd',
|
|
132
132
|
'...',
|
|
133
|
-
])('
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
verifier,
|
|
139
|
-
hasher: digest,
|
|
140
|
-
saltGenerator: generateSalt,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const disclosure = new Disclosure([
|
|
144
|
-
await generateSalt(16),
|
|
145
|
-
reservedClaimName,
|
|
146
|
-
'reserved',
|
|
147
|
-
]);
|
|
148
|
-
const disclosureDigest = await disclosure.digest({
|
|
149
|
-
hasher: digest,
|
|
150
|
-
alg: 'sha-256',
|
|
151
|
-
});
|
|
152
|
-
const header = Buffer.from(JSON.stringify({ alg: 'EdDSA' })).toString(
|
|
153
|
-
'base64url',
|
|
154
|
-
);
|
|
155
|
-
const payload = Buffer.from(
|
|
156
|
-
JSON.stringify({
|
|
157
|
-
_sd: [disclosureDigest],
|
|
158
|
-
iss: 'Issuer',
|
|
159
|
-
iat: Math.floor(Date.now() / 1000),
|
|
160
|
-
vct: '',
|
|
161
|
-
_sd_alg: 'sha-256',
|
|
162
|
-
}),
|
|
163
|
-
).toString('base64url');
|
|
164
|
-
const unsignedJwt = `${header}.${payload}`;
|
|
165
|
-
const signature = await signer(unsignedJwt);
|
|
166
|
-
|
|
167
|
-
await expect(
|
|
168
|
-
sdjwt.validate(`${unsignedJwt}.${signature}~${disclosure.encode()}~`),
|
|
169
|
-
).rejects.toThrow(
|
|
170
|
-
`Reserved field name "${reservedClaimName}" is not allowed`,
|
|
133
|
+
])('rejects disclosure resolving to reserved claim name %s', async (reservedClaimName) => {
|
|
134
|
+
expect(
|
|
135
|
+
() => new Disclosure(['salt', reservedClaimName, 'reserved']),
|
|
136
|
+
).toThrow(
|
|
137
|
+
`Reserved disclosure claim name "${reservedClaimName}" is not allowed`,
|
|
171
138
|
);
|
|
172
139
|
});
|
|
173
140
|
|
package/src/test/kbjwt.spec.ts
CHANGED
|
@@ -498,7 +498,7 @@ describe('KB JWT', () => {
|
|
|
498
498
|
expect(verified.payload.exp).toBe(1000);
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
test('
|
|
501
|
+
test('rejects draft-era _sd_hash without sd_hash', async () => {
|
|
502
502
|
const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
|
|
503
503
|
const testSigner: Signer = async (data: string) => {
|
|
504
504
|
const sig = Crypto.sign(null, Buffer.from(data), privateKey);
|
|
@@ -544,22 +544,12 @@ describe('KB JWT', () => {
|
|
|
544
544
|
|
|
545
545
|
const encodedKbJwt = await kbJwt.sign(testSigner);
|
|
546
546
|
const decoded = KBJwt.fromKBEncode(encodedKbJwt);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
});
|
|
552
|
-
expect(verified).toStrictEqual({
|
|
553
|
-
header: {
|
|
554
|
-
typ: KB_JWT_TYP,
|
|
555
|
-
alg: 'EdDSA',
|
|
556
|
-
},
|
|
557
|
-
payload: {
|
|
558
|
-
iat: 1,
|
|
559
|
-
aud: 'aud',
|
|
547
|
+
await expect(
|
|
548
|
+
decoded.verifyKB({
|
|
549
|
+
verifier: testVerifier,
|
|
550
|
+
payload,
|
|
560
551
|
nonce: 'nonce',
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
});
|
|
552
|
+
}),
|
|
553
|
+
).rejects.toThrow('Invalid Key Binding Jwt');
|
|
564
554
|
});
|
|
565
555
|
});
|
package/src/types/type.ts
CHANGED
|
@@ -9,6 +9,12 @@ export type Base64urlString = string;
|
|
|
9
9
|
|
|
10
10
|
export type DisclosureData<T> = [string, string, T] | [string, T];
|
|
11
11
|
|
|
12
|
+
export const encodePathSegment = (segment: string) =>
|
|
13
|
+
segment.replace(/~/g, '~0').replace(/\./g, '~1');
|
|
14
|
+
|
|
15
|
+
export const encodePath = (segments: string[]) =>
|
|
16
|
+
segments.map(encodePathSegment).join('.');
|
|
17
|
+
|
|
12
18
|
// based on https://www.iana.org/assignments/named-information/named-information.xhtml
|
|
13
19
|
export const IANA_HASH_ALGORITHMS = [
|
|
14
20
|
'sha-256',
|
|
@@ -32,10 +38,25 @@ export const IANA_HASH_ALGORITHMS = [
|
|
|
32
38
|
|
|
33
39
|
export type HashAlgorithm = (typeof IANA_HASH_ALGORITHMS)[number];
|
|
34
40
|
|
|
41
|
+
export const DEFAULT_SECURE_HASH_ALGORITHMS = [
|
|
42
|
+
'sha-256',
|
|
43
|
+
'sha-384',
|
|
44
|
+
'sha-512',
|
|
45
|
+
'sha3-256',
|
|
46
|
+
'sha3-384',
|
|
47
|
+
'sha3-512',
|
|
48
|
+
'blake2s-256',
|
|
49
|
+
'blake2b-256',
|
|
50
|
+
'blake2b-512',
|
|
51
|
+
'k12-256',
|
|
52
|
+
'k12-512',
|
|
53
|
+
] as const satisfies ReadonlyArray<HashAlgorithm>;
|
|
54
|
+
|
|
35
55
|
export type SDJWTConfig<T = unknown> = {
|
|
36
56
|
omitTyp?: boolean;
|
|
37
57
|
hasher?: Hasher;
|
|
38
58
|
hashAlg?: HashAlgorithm;
|
|
59
|
+
allowedDisclosureHashAlgorithms?: ReadonlyArray<HashAlgorithm>;
|
|
39
60
|
saltGenerator?: SaltGenerator;
|
|
40
61
|
signer?: Signer;
|
|
41
62
|
signAlg?: string;
|
package/src/utils/disclosure.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type DisclosureData,
|
|
3
|
+
type HasherAndAlg,
|
|
4
|
+
type HasherAndAlgSync,
|
|
5
|
+
SD_DIGEST,
|
|
6
|
+
SD_LIST_KEY,
|
|
7
|
+
} from '../types';
|
|
2
8
|
import { base64urlEncode, uint8ArrayToBase64Url } from './base64url';
|
|
3
9
|
import { SDJWTException } from './error';
|
|
4
10
|
import { decodeBase64urlJsonStrict } from './strict-json';
|
|
5
11
|
|
|
6
12
|
export class Disclosure<T = unknown> {
|
|
7
|
-
public salt
|
|
13
|
+
public salt!: string;
|
|
8
14
|
public key?: string;
|
|
9
|
-
public value
|
|
15
|
+
public value!: T;
|
|
10
16
|
public _digest: string | undefined;
|
|
11
17
|
private _encoded: string | undefined;
|
|
12
18
|
|
|
@@ -14,6 +20,13 @@ export class Disclosure<T = unknown> {
|
|
|
14
20
|
data: DisclosureData<T>,
|
|
15
21
|
_meta?: { digest: string; encoded: string },
|
|
16
22
|
) {
|
|
23
|
+
if (!Array.isArray(data) || (data.length !== 2 && data.length !== 3)) {
|
|
24
|
+
throw new SDJWTException('Invalid disclosure data');
|
|
25
|
+
}
|
|
26
|
+
if (typeof data[0] !== 'string') {
|
|
27
|
+
throw new SDJWTException('Invalid disclosure salt');
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
// If the meta is provided, then we assume that the data is already encoded and digested
|
|
18
31
|
this._digest = _meta?.digest;
|
|
19
32
|
this._encoded = _meta?.encoded;
|
|
@@ -24,12 +37,19 @@ export class Disclosure<T = unknown> {
|
|
|
24
37
|
return;
|
|
25
38
|
}
|
|
26
39
|
if (data.length === 3) {
|
|
40
|
+
if (typeof data[1] !== 'string') {
|
|
41
|
+
throw new SDJWTException('Invalid disclosure claim name');
|
|
42
|
+
}
|
|
43
|
+
if (data[1] === SD_DIGEST || data[1] === SD_LIST_KEY) {
|
|
44
|
+
throw new SDJWTException(
|
|
45
|
+
`Reserved disclosure claim name "${data[1]}" is not allowed`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
27
48
|
this.salt = data[0];
|
|
28
49
|
this.key = data[1];
|
|
29
50
|
this.value = data[2];
|
|
30
51
|
return;
|
|
31
52
|
}
|
|
32
|
-
throw new SDJWTException('Invalid disclosure data');
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
// We need to digest of the original encoded data.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import Crypto from 'node:crypto';
|
|
2
|
+
import { hasher as digest, generateSalt } from '@owf/crypto';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { unpackObj } from '../src/decode';
|
|
5
|
+
import { GeneralJSON, Jwt, KBJwt, SDJwtInstance } from '../src/index';
|
|
6
|
+
import { selectDisclosures, transformPresentationFrame } from '../src/present';
|
|
7
|
+
import type { Signer, Verifier } from '../src/types';
|
|
8
|
+
import { Disclosure } from '../src/utils';
|
|
9
|
+
|
|
10
|
+
const createSignerVerifier = () => {
|
|
11
|
+
const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
|
|
12
|
+
const signer: Signer = async (data: string) => {
|
|
13
|
+
const sig = Crypto.sign(null, Buffer.from(data), privateKey);
|
|
14
|
+
return Buffer.from(sig).toString('base64url');
|
|
15
|
+
};
|
|
16
|
+
const verifier: Verifier = async (data: string, sig: string) => {
|
|
17
|
+
return Crypto.verify(
|
|
18
|
+
null,
|
|
19
|
+
Buffer.from(data),
|
|
20
|
+
publicKey,
|
|
21
|
+
Buffer.from(sig, 'base64url'),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
return { signer, verifier };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('RFC 9901 audit fixes', () => {
|
|
28
|
+
test('rejects malformed disclosure containers', () => {
|
|
29
|
+
const objectDisclosure = Disclosure.fromArray(['salt', 'name', 'Alice'], {
|
|
30
|
+
digest: 'object-digest',
|
|
31
|
+
encoded: 'object-disclosure',
|
|
32
|
+
});
|
|
33
|
+
const arrayDisclosure = Disclosure.fromArray(['salt', 'Alice'], {
|
|
34
|
+
digest: 'array-digest',
|
|
35
|
+
encoded: 'array-disclosure',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(() => unpackObj({ _sd: 'object-digest' }, {})).toThrow(
|
|
39
|
+
'Invalid _sd claim: expected array of strings',
|
|
40
|
+
);
|
|
41
|
+
expect(() =>
|
|
42
|
+
unpackObj({ items: [{ '...': 'array-digest', extra: true }] }, {}),
|
|
43
|
+
).toThrow('Invalid array disclosure placeholder');
|
|
44
|
+
expect(() =>
|
|
45
|
+
unpackObj({ _sd: ['array-digest'] }, { 'array-digest': arrayDisclosure }),
|
|
46
|
+
).toThrow('Array disclosure cannot be used as an object property');
|
|
47
|
+
expect(() =>
|
|
48
|
+
unpackObj(
|
|
49
|
+
{ items: [{ '...': 'object-digest' }] },
|
|
50
|
+
{ 'object-digest': objectDisclosure },
|
|
51
|
+
),
|
|
52
|
+
).toThrow('Object-property disclosure cannot be used as an array element');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('rejects disclosed claim name collisions at the same object level', () => {
|
|
56
|
+
const first = Disclosure.fromArray(['salt-1', 'name', 'Alice'], {
|
|
57
|
+
digest: 'digest-1',
|
|
58
|
+
encoded: 'disclosure-1',
|
|
59
|
+
});
|
|
60
|
+
const second = Disclosure.fromArray(['salt-2', 'name', 'Mallory'], {
|
|
61
|
+
digest: 'digest-2',
|
|
62
|
+
encoded: 'disclosure-2',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(() =>
|
|
66
|
+
unpackObj(
|
|
67
|
+
{ _sd: ['digest-1', 'digest-2'] },
|
|
68
|
+
{ 'digest-1': first, 'digest-2': second },
|
|
69
|
+
),
|
|
70
|
+
).toThrow('Disclosed claim name "name" conflicts with another disclosure');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('validates JWT claims after disclosure processing', async () => {
|
|
74
|
+
const { signer, verifier } = createSignerVerifier();
|
|
75
|
+
const sdjwt = new SDJwtInstance({
|
|
76
|
+
signer,
|
|
77
|
+
signAlg: 'EdDSA',
|
|
78
|
+
verifier,
|
|
79
|
+
hasher: digest,
|
|
80
|
+
saltGenerator: generateSalt,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const disclosure = new Disclosure(['salt', 'exp', 1]);
|
|
84
|
+
const disclosureDigest = await disclosure.digest({
|
|
85
|
+
hasher: digest,
|
|
86
|
+
alg: 'sha-256',
|
|
87
|
+
});
|
|
88
|
+
const header = Buffer.from(JSON.stringify({ alg: 'EdDSA' })).toString(
|
|
89
|
+
'base64url',
|
|
90
|
+
);
|
|
91
|
+
const payload = Buffer.from(
|
|
92
|
+
JSON.stringify({ _sd: [disclosureDigest], _sd_alg: 'sha-256' }),
|
|
93
|
+
).toString('base64url');
|
|
94
|
+
const unsignedJwt = `${header}.${payload}`;
|
|
95
|
+
const signature = await signer(unsignedJwt);
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
sdjwt.validate(`${unsignedJwt}.${signature}~${disclosure.encode()}~`, {
|
|
99
|
+
currentDate: 1,
|
|
100
|
+
}),
|
|
101
|
+
).rejects.toThrow('Verify Error: JWT is expired');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('rejects unsafe JWT algorithm and invalid validity claims', async () => {
|
|
105
|
+
const jwt = new Jwt({
|
|
106
|
+
header: { alg: 'none' },
|
|
107
|
+
payload: { exp: 100 },
|
|
108
|
+
signature: 'signature',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await expect(jwt.verify(() => true)).rejects.toThrow(
|
|
112
|
+
'Verify Error: alg "none" is not allowed',
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const { signer, verifier } = createSignerVerifier();
|
|
116
|
+
const signed = new Jwt({
|
|
117
|
+
header: { alg: 'EdDSA' },
|
|
118
|
+
payload: { exp: '100' },
|
|
119
|
+
});
|
|
120
|
+
await signed.sign(signer);
|
|
121
|
+
|
|
122
|
+
await expect(signed.verify(verifier)).rejects.toThrow(
|
|
123
|
+
'Verify Error: JWT exp must be a number',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('validates expected issuer audience', async () => {
|
|
128
|
+
const { signer, verifier } = createSignerVerifier();
|
|
129
|
+
const jwt = new Jwt({
|
|
130
|
+
header: { alg: 'EdDSA' },
|
|
131
|
+
payload: { aud: 'verifier-a' },
|
|
132
|
+
});
|
|
133
|
+
await jwt.sign(signer);
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
jwt.verify(verifier, { expectedAudience: 'verifier-b' }),
|
|
137
|
+
).rejects.toThrow('Verify Error: Invalid audience');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('validates key binding audience, age, and claim types', async () => {
|
|
141
|
+
const { signer } = createSignerVerifier();
|
|
142
|
+
const kbJwt = new KBJwt({
|
|
143
|
+
header: { typ: 'kb+jwt', alg: 'EdDSA' },
|
|
144
|
+
payload: { iat: 900, aud: 'verifier-a', nonce: 'nonce', sd_hash: 'hash' },
|
|
145
|
+
});
|
|
146
|
+
await kbJwt.sign(signer);
|
|
147
|
+
|
|
148
|
+
await expect(
|
|
149
|
+
kbJwt.verifyKB({
|
|
150
|
+
verifier: () => true,
|
|
151
|
+
payload: {},
|
|
152
|
+
nonce: 'nonce',
|
|
153
|
+
options: { expectedKeyBindingAudience: 'verifier-b' },
|
|
154
|
+
}),
|
|
155
|
+
).rejects.toThrow('Verify Error: Invalid Key Binding audience');
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
kbJwt.verifyKB({
|
|
159
|
+
verifier: () => true,
|
|
160
|
+
payload: {},
|
|
161
|
+
nonce: 'nonce',
|
|
162
|
+
options: { currentDate: 1000, keyBindingMaxAgeSeconds: 50 },
|
|
163
|
+
}),
|
|
164
|
+
).rejects.toThrow('Verify Error: Key Binding JWT is too old');
|
|
165
|
+
|
|
166
|
+
(kbJwt.payload as Record<string, unknown>).iat = '900';
|
|
167
|
+
await expect(
|
|
168
|
+
kbJwt.verifyKB({ verifier: () => true, payload: {}, nonce: 'nonce' }),
|
|
169
|
+
).rejects.toThrow('Invalid Key Binding Jwt');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('rejects insecure disclosure hash algorithms by default', () => {
|
|
173
|
+
const { signer, verifier } = createSignerVerifier();
|
|
174
|
+
|
|
175
|
+
expect(
|
|
176
|
+
() =>
|
|
177
|
+
new SDJwtInstance({
|
|
178
|
+
signer,
|
|
179
|
+
signAlg: 'EdDSA',
|
|
180
|
+
verifier,
|
|
181
|
+
hasher: digest,
|
|
182
|
+
saltGenerator: generateSalt,
|
|
183
|
+
hashAlg: 'sha-256-32',
|
|
184
|
+
}),
|
|
185
|
+
).toThrow('Disallowed hash algorithm: sha-256-32');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('rejects duplicate salts during issuance', async () => {
|
|
189
|
+
const { signer, verifier } = createSignerVerifier();
|
|
190
|
+
const sdjwt = new SDJwtInstance({
|
|
191
|
+
signer,
|
|
192
|
+
signAlg: 'EdDSA',
|
|
193
|
+
verifier,
|
|
194
|
+
hasher: digest,
|
|
195
|
+
saltGenerator: () => 'same-salt',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await expect(
|
|
199
|
+
sdjwt.issue({ first: 'a', second: 'b' }, { _sd: ['first', 'second'] }),
|
|
200
|
+
).rejects.toThrow('Duplicate disclosure salt detected');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('rejects disclosures and kb_jwt outside the first General JSON header', () => {
|
|
204
|
+
expect(() =>
|
|
205
|
+
GeneralJSON.fromSerialized({
|
|
206
|
+
payload: 'payload',
|
|
207
|
+
signatures: [
|
|
208
|
+
{ protected: 'header-1', signature: 'sig-1', header: {} },
|
|
209
|
+
{
|
|
210
|
+
protected: 'header-2',
|
|
211
|
+
signature: 'sig-2',
|
|
212
|
+
header: { disclosures: [] },
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
}),
|
|
216
|
+
).toThrow(
|
|
217
|
+
'disclosures and kb_jwt MUST only appear in the first unprotected header',
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('escapes presentation paths with literal dots in claim names', () => {
|
|
222
|
+
expect(transformPresentationFrame({ 'a.b': true })).toEqual(['a~1b']);
|
|
223
|
+
expect(transformPresentationFrame({ a: { b: true } })).toEqual([
|
|
224
|
+
'a',
|
|
225
|
+
'a.b',
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
const selected = selectDisclosures(
|
|
229
|
+
{ _sd: ['top'], a: { _sd: ['nested'] } },
|
|
230
|
+
[
|
|
231
|
+
{
|
|
232
|
+
digest: 'top',
|
|
233
|
+
encoded: 'top-disclosure',
|
|
234
|
+
salt: 'salt-1',
|
|
235
|
+
key: 'a.b',
|
|
236
|
+
value: 'top-level',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
digest: 'nested',
|
|
240
|
+
encoded: 'nested-disclosure',
|
|
241
|
+
salt: 'salt-2',
|
|
242
|
+
key: 'b',
|
|
243
|
+
value: 'nested',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
{ a: { b: true } },
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(selected).toHaveLength(1);
|
|
250
|
+
expect(selected[0].digest).toBe('nested');
|
|
251
|
+
});
|
|
252
|
+
});
|