@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/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 newKey = prefix ? `${prefix}.${key}` : key;
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(16);
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(16);
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 sdjwt without disclosures', () => {
42
+ test('split rejects bare jwt without SD-JWT separator', () => {
43
43
  const sdjwt = 'h.p.s';
44
- const { jwt, disclosures, kbJwt } = splitSdJwt(sdjwt);
45
- expect(jwt).toBe('h.p.s');
46
- expect(disclosures).toStrictEqual([]);
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
- const decodedSdJwt = await decodeSdJwt(jwt, digest);
72
- expect(decodedSdJwt).toBeDefined();
73
- expect(decodedSdJwt.kbJwt).toBeUndefined();
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 sync', () => {
85
+ test('decode sync rejects bare jwt without SD-JWT separator', () => {
89
86
  const jwt =
90
87
  'eyJhbGciOiJIUzI1NiIsInR5cCI6InNkK2p3dCJ9.eyJsYXN0bmFtZSI6IkRvZSIsInNzbiI6IjEyMy00NS02Nzg5IiwiX3NkIjpbIk4yUXhZV1UxTlRnME1qQmpOR1JpWVRCaU1tRmtaamN5WXpSbFpXUmhaRGd5WkRCbE1qaGhZVGcwTnpJMU9XSXpZek5qWkdNNE1qZG1NVGN6TmpZd05RIiwiWlRSalkyUTVOemRoWkRVM05tWTFZV0UyTmpka01XVmpNRE16WXpOak5qQmtNak5pT0dZelpHSTBOelV4TURsak9EWTRNREEzWm1JeFpUY3daREZqTmciXSwiX3NkX2FsZyI6InNoYS0yNTYifQ.mX14Sw86xy8NFQta7tCfNmhVCqzfaJ_K3VEIhTjbLDY';
91
- const decodedSdJwt = decodeSdJwtSync(jwt, digest);
92
- expect(decodedSdJwt).toBeDefined();
93
- expect(decodedSdJwt.kbJwt).toBeUndefined();
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)', () => {
@@ -130,44 +130,11 @@ describe('index', () => {
130
130
  test.each([
131
131
  '_sd',
132
132
  '...',
133
- ])('validate rejects disclosure resolving to reserved claim name %s', async (reservedClaimName) => {
134
- const { signer, verifier } = createSignerVerifier();
135
- const sdjwt = new SDJwtInstance<SdJwtPayload>({
136
- signer,
137
- signAlg: 'EdDSA',
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
 
@@ -498,7 +498,7 @@ describe('KB JWT', () => {
498
498
  expect(verified.payload.exp).toBe(1000);
499
499
  });
500
500
 
501
- test('compatibility test for version 06', async () => {
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
- const verified = await decoded.verifyKB({
548
- verifier: testVerifier,
549
- payload,
550
- nonce: 'nonce',
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
- _sd_hash: 'hash',
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;
@@ -1,12 +1,18 @@
1
- import type { DisclosureData, HasherAndAlg, HasherAndAlgSync } from '../types';
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: string;
13
+ public salt!: string;
8
14
  public key?: string;
9
- public value: T;
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
+ });