@sd-jwt/core 0.19.1-next.9 → 0.20.1-next.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.
@@ -1,12 +1,15 @@
1
- import type { HasherAndAlgSync, HasherSync } from '../types';
2
1
  import {
3
2
  type Hasher,
4
3
  type HasherAndAlg,
4
+ type HasherAndAlgSync,
5
+ type HasherSync,
6
+ IANA_HASH_ALGORITHMS,
5
7
  SD_DIGEST,
6
8
  SD_LIST_KEY,
7
9
  SD_SEPARATOR,
8
10
  } from '../types';
9
- import { base64urlDecode, Disclosure, SDJWTException } from '../utils';
11
+ import { Disclosure, SDJWTException } from '../utils';
12
+ import { decodeBase64urlJsonStrict } from '../utils/strict-json';
10
13
 
11
14
  export const decodeJwt = <
12
15
  H extends Record<string, unknown>,
@@ -20,8 +23,8 @@ export const decodeJwt = <
20
23
  }
21
24
 
22
25
  return {
23
- header: JSON.parse(base64urlDecode(header)),
24
- payload: JSON.parse(base64urlDecode(payload)),
26
+ header: decodeBase64urlJsonStrict(header, 'Invalid JWT as input'),
27
+ payload: decodeBase64urlJsonStrict(payload, 'Invalid JWT as input'),
25
28
  signature: signature,
26
29
  };
27
30
  };
@@ -319,6 +322,13 @@ export const getSDAlgAndPayload = (SdJwtPayload: Record<string, unknown>) => {
319
322
  // This is for compatibility
320
323
  return { _sd_alg: 'sha-256', payload };
321
324
  }
325
+ if (
326
+ !IANA_HASH_ALGORITHMS.includes(
327
+ _sd_alg as (typeof IANA_HASH_ALGORITHMS)[number],
328
+ )
329
+ ) {
330
+ throw new SDJWTException(`Invalid _sd_alg: ${_sd_alg}`);
331
+ }
322
332
  return { _sd_alg, payload };
323
333
  };
324
334
 
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  type KBOptions,
13
13
  type PresentationFrame,
14
14
  type SafeVerifyResult,
15
+ SD_DECOY,
16
+ SD_DIGEST,
15
17
  type SDJWTCompact,
16
18
  type SDJWTConfig,
17
19
  type Signer,
@@ -19,12 +21,12 @@ import {
19
21
  type VerificationErrorCode,
20
22
  } from './types';
21
23
  import {
22
- base64urlDecode,
23
24
  base64urlEncode,
24
25
  ensureError,
25
26
  SDJWTException,
26
27
  uint8ArrayToBase64Url,
27
28
  } from './utils';
29
+ import { decodeBase64urlJsonStrict } from './utils/strict-json';
28
30
 
29
31
  export * from './decode';
30
32
  export * from './decoy';
@@ -120,9 +122,8 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
120
122
  throw new SDJWTException('sign alogrithm not specified');
121
123
  }
122
124
 
123
- if (disclosureFrame) {
124
- this.validateReservedFields<Payload>(disclosureFrame);
125
- }
125
+ this.validateReservedFields<Payload>(payload);
126
+ this.validateDisclosureFrame<Payload>(disclosureFrame);
126
127
 
127
128
  const hasher = this.userConfig.hasher;
128
129
  const hashAlg = this.userConfig.hashAlg ?? SDJwtInstance.DEFAULT_hashAlg;
@@ -157,12 +158,35 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
157
158
  }
158
159
 
159
160
  /**
160
- * Validates if the disclosureFrame contains any reserved fields. If so it will throw an error.
161
- * @param disclosureFrame
161
+ * Validates if the payload contains any reserved claim names. If so it will throw an error.
162
+ * @param payload
162
163
  * @returns
163
164
  */
164
- protected validateReservedFields<T extends ExtendedPayload>(
165
- _disclosureFrame: DisclosureFrame<T>,
165
+ protected validateReservedFields<T extends ExtendedPayload>(payload: T) {
166
+ const reservedFields = new Set([SD_DIGEST, '_sd_alg', SD_DECOY]);
167
+
168
+ const visit = (node: unknown) => {
169
+ if (!node || typeof node !== 'object') {
170
+ return;
171
+ }
172
+
173
+ for (const [key, value] of Object.entries(
174
+ node as Record<string, unknown>,
175
+ )) {
176
+ if (reservedFields.has(key)) {
177
+ throw new SDJWTException(
178
+ `Reserved field name "${key}" is not allowed`,
179
+ );
180
+ }
181
+ visit(value);
182
+ }
183
+ };
184
+
185
+ visit(payload);
186
+ }
187
+
188
+ protected validateDisclosureFrame<T extends ExtendedPayload>(
189
+ _disclosureFrame?: DisclosureFrame<T>,
166
190
  ) {
167
191
  return;
168
192
  }
@@ -242,6 +266,7 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
242
266
  verifier: this.userConfig.kbVerifier,
243
267
  payload,
244
268
  nonce: options.keyBindingNonce,
269
+ options,
245
270
  });
246
271
  if (!kb) {
247
272
  throw new Error('signature is not valid');
@@ -414,6 +439,7 @@ export class SDJwtInstance<ExtendedPayload extends SdJwtPayload, T = unknown> {
414
439
  verifier: this.userConfig.kbVerifier,
415
440
  payload,
416
441
  nonce: options.keyBindingNonce,
442
+ options,
417
443
  });
418
444
  if (!kbResult) {
419
445
  addError(
@@ -627,9 +653,8 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
627
653
  throw new SDJWTException('SaltGenerator not found');
628
654
  }
629
655
 
630
- if (disclosureFrame) {
631
- this.validateReservedFields<Payload>(disclosureFrame);
632
- }
656
+ this.validateReservedFields<Payload>(payload);
657
+ this.validateDisclosureFrame<Payload>(disclosureFrame);
633
658
 
634
659
  const hasher = this.userConfig.hasher;
635
660
  const hashAlg = this.userConfig.hashAlg ?? SDJwtInstance.DEFAULT_hashAlg;
@@ -641,12 +666,15 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
641
666
  this.userConfig.saltGenerator,
642
667
  );
643
668
 
644
- const encodedDisclosures = disclosures.map((d) => d.encode());
645
669
  const encodedSDJwtPayload = this.encodeObj({
646
670
  ...packedClaims,
647
671
  _sd_alg: disclosureFrame ? hashAlg : undefined,
648
672
  });
649
673
 
674
+ const encodedDisclosures = disclosures.map((disclosure) =>
675
+ disclosure.encode(),
676
+ );
677
+
650
678
  const signatures = await Promise.all(
651
679
  options.sigs.map(async (s) => {
652
680
  const { signer, alg, kid, header } = s;
@@ -658,7 +686,6 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
658
686
 
659
687
  return {
660
688
  protected: encodedProtectedHeader,
661
- kid,
662
689
  signature,
663
690
  };
664
691
  }),
@@ -674,12 +701,35 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
674
701
  }
675
702
 
676
703
  /**
677
- * Validates if the disclosureFrame contains any reserved fields. If so it will throw an error.
678
- * @param disclosureFrame
704
+ * Validates if the payload contains any reserved claim names. If so it will throw an error.
705
+ * @param payload
679
706
  * @returns
680
707
  */
681
- protected validateReservedFields<T extends ExtendedPayload>(
682
- _disclosureFrame: DisclosureFrame<T>,
708
+ protected validateReservedFields<T extends ExtendedPayload>(payload: T) {
709
+ const reservedFields = new Set([SD_DIGEST, '_sd_alg', SD_DECOY]);
710
+
711
+ const visit = (node: unknown) => {
712
+ if (!node || typeof node !== 'object') {
713
+ return;
714
+ }
715
+
716
+ for (const [key, value] of Object.entries(
717
+ node as Record<string, unknown>,
718
+ )) {
719
+ if (reservedFields.has(key)) {
720
+ throw new SDJWTException(
721
+ `Reserved field name "${key}" is not allowed`,
722
+ );
723
+ }
724
+ visit(value);
725
+ }
726
+ };
727
+
728
+ visit(payload);
729
+ }
730
+
731
+ protected validateDisclosureFrame<T extends ExtendedPayload>(
732
+ _disclosureFrame?: DisclosureFrame<T>,
683
733
  ) {
684
734
  return;
685
735
  }
@@ -774,6 +824,7 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
774
824
  verifier: this.userConfig.kbVerifier,
775
825
  payload,
776
826
  nonce: options.keyBindingNonce,
827
+ options,
777
828
  });
778
829
  if (!kb) {
779
830
  throw new Error('signature is not valid');
@@ -833,7 +884,7 @@ export class SDJwtGeneralJSONInstance<ExtendedPayload extends SdJwtPayload> {
833
884
  `${encodedHeader}.${payload}`,
834
885
  signature,
835
886
  );
836
- const header = JSON.parse(base64urlDecode(encodedHeader));
887
+ const header = decodeBase64urlJsonStrict(encodedHeader, 'Invalid JWT');
837
888
  return { verified, header };
838
889
  }),
839
890
  );
package/src/jwt.ts CHANGED
@@ -37,6 +37,13 @@ export type VerifierOptions = {
37
37
  */
38
38
  keyBindingNonce?: string;
39
39
 
40
+ /**
41
+ * disable the verification of the status claim in the payload.
42
+ *
43
+ * @default false
44
+ */
45
+ disableStatusVerification?: boolean;
46
+
40
47
  /**
41
48
  * any other custom options
42
49
  */
package/src/kbjwt.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Jwt } from './jwt';
1
+ import { Jwt, type VerifierOptions } from './jwt';
2
2
  import {
3
3
  KB_JWT_TYP,
4
4
  type KbVerifier,
@@ -17,6 +17,11 @@ export class KBJwt<
17
17
  verifier: KbVerifier;
18
18
  payload: Record<string, unknown>;
19
19
  nonce: string;
20
+ /**
21
+ * Options forwarded to the common JWT verification, e.g. currentDate and
22
+ * skewSeconds used to validate the iat, nbf and exp claims.
23
+ */
24
+ options?: VerifierOptions;
20
25
  }) {
21
26
  if (!this.header || !this.payload || !this.signature) {
22
27
  throw new SDJWTException('Verify Error: Invalid JWT');
@@ -39,19 +44,19 @@ export class KBJwt<
39
44
  throw new SDJWTException('Invalid Key Binding Jwt');
40
45
  }
41
46
 
42
- const data = this.getUnsignedToken();
43
- const verified = await values.verifier(
44
- data,
45
- this.signature,
46
- values.payload,
47
- );
48
- if (!verified) {
49
- throw new SDJWTException('Verify Error: Invalid JWT Signature');
50
- }
51
47
  if (this.payload.nonce !== values.nonce) {
52
48
  throw new SDJWTException('Verify Error: Invalid Nonce');
53
49
  }
54
50
 
51
+ // Delegate signature verification and common JWT claim validation
52
+ // (iat, nbf, exp) to the shared Jwt.verify implementation. The kbVerifier
53
+ // needs the kb+jwt payload (e.g. the holder's cnf key), so we wrap it to
54
+ // forward values.payload instead of the base verifier's options argument.
55
+ await this.verify(
56
+ (data, sig) => values.verifier(data, sig, values.payload),
57
+ values.options,
58
+ );
59
+
55
60
  return { payload: this.payload, header: this.header };
56
61
  }
57
62
 
@@ -100,6 +100,32 @@ describe('index', () => {
100
100
  expect(credential).toBeDefined();
101
101
  });
102
102
 
103
+ test('issue rejects reserved disclosure frame keys', async () => {
104
+ const { signer, verifier } = createSignerVerifier();
105
+ const sdjwt = new SDJwtInstance<SdJwtPayload>({
106
+ signer,
107
+ signAlg: 'EdDSA',
108
+ verifier,
109
+ hasher: digest,
110
+ saltGenerator: generateSalt,
111
+ });
112
+
113
+ await expect(
114
+ sdjwt.issue(
115
+ {
116
+ foo: 'bar',
117
+ _sd: 'reserved',
118
+ iss: 'Issuer',
119
+ iat: Math.floor(Date.now() / 1000),
120
+ vct: '',
121
+ },
122
+ {
123
+ _sd: ['foo'],
124
+ },
125
+ ),
126
+ ).rejects.toThrow('Reserved field name "_sd" is not allowed');
127
+ });
128
+
103
129
  test('verify failed', async () => {
104
130
  const { signer } = createSignerVerifier();
105
131
  const { publicKey } = Crypto.generateKeyPairSync('ed25519');
@@ -269,6 +295,140 @@ describe('index', () => {
269
295
  expect(results).toBeDefined();
270
296
  });
271
297
 
298
+ test('verify rejects an expired kbJwt during full verification', async () => {
299
+ const { signer, verifier } = createSignerVerifier();
300
+ const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
301
+
302
+ const kbVerifier: KbVerifier = async (
303
+ data: string,
304
+ sig: string,
305
+ payload: JwtPayload,
306
+ ) => {
307
+ if (!payload.cnf) throw Error('key binding not supported');
308
+ return Crypto.verify(
309
+ null,
310
+ Buffer.from(data),
311
+ (await importJWK(payload.cnf.jwk as JWK, 'EdDSA')) as KeyLike,
312
+ Buffer.from(sig, 'base64url'),
313
+ );
314
+ };
315
+
316
+ const kbSigner = (data: string) => {
317
+ const sig = Crypto.sign(null, Buffer.from(data), privateKey);
318
+ return Buffer.from(sig).toString('base64url');
319
+ };
320
+
321
+ const sdjwt = new SDJwtInstance<SdJwtPayload>({
322
+ signer,
323
+ signAlg: 'EdDSA',
324
+ verifier,
325
+ hasher: digest,
326
+ saltGenerator: generateSalt,
327
+ kbSigner,
328
+ kbVerifier,
329
+ kbSignAlg: 'EdDSA',
330
+ });
331
+
332
+ const now = Math.floor(Date.now() / 1000);
333
+ const credential = await sdjwt.issue(
334
+ {
335
+ foo: 'bar',
336
+ iat: now,
337
+ cnf: { jwk: await exportJWK(publicKey) },
338
+ },
339
+ { _sd: ['foo'] },
340
+ );
341
+
342
+ const presentation = await sdjwt.present(
343
+ credential,
344
+ { foo: true },
345
+ {
346
+ kb: {
347
+ payload: {
348
+ aud: '1',
349
+ iat: now - 7200,
350
+ nonce: '342',
351
+ // kb+jwt expired one hour ago
352
+ exp: now - 3600,
353
+ } as never,
354
+ },
355
+ },
356
+ );
357
+
358
+ await expect(
359
+ sdjwt.verify(presentation, {
360
+ requiredClaimKeys: ['foo'],
361
+ keyBindingNonce: '342',
362
+ }),
363
+ ).rejects.toThrow('Verify Error: JWT is expired');
364
+ });
365
+
366
+ test('verify accepts a kbJwt with a future exp during full verification', async () => {
367
+ const { signer, verifier } = createSignerVerifier();
368
+ const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
369
+
370
+ const kbVerifier: KbVerifier = async (
371
+ data: string,
372
+ sig: string,
373
+ payload: JwtPayload,
374
+ ) => {
375
+ if (!payload.cnf) throw Error('key binding not supported');
376
+ return Crypto.verify(
377
+ null,
378
+ Buffer.from(data),
379
+ (await importJWK(payload.cnf.jwk as JWK, 'EdDSA')) as KeyLike,
380
+ Buffer.from(sig, 'base64url'),
381
+ );
382
+ };
383
+
384
+ const kbSigner = (data: string) => {
385
+ const sig = Crypto.sign(null, Buffer.from(data), privateKey);
386
+ return Buffer.from(sig).toString('base64url');
387
+ };
388
+
389
+ const sdjwt = new SDJwtInstance<SdJwtPayload>({
390
+ signer,
391
+ signAlg: 'EdDSA',
392
+ verifier,
393
+ hasher: digest,
394
+ saltGenerator: generateSalt,
395
+ kbSigner,
396
+ kbVerifier,
397
+ kbSignAlg: 'EdDSA',
398
+ });
399
+
400
+ const now = Math.floor(Date.now() / 1000);
401
+ const credential = await sdjwt.issue(
402
+ {
403
+ foo: 'bar',
404
+ iat: now,
405
+ cnf: { jwk: await exportJWK(publicKey) },
406
+ },
407
+ { _sd: ['foo'] },
408
+ );
409
+
410
+ const presentation = await sdjwt.present(
411
+ credential,
412
+ { foo: true },
413
+ {
414
+ kb: {
415
+ payload: {
416
+ aud: '1',
417
+ iat: now,
418
+ nonce: '342',
419
+ exp: now + 3600,
420
+ } as never,
421
+ },
422
+ },
423
+ );
424
+
425
+ const results = await sdjwt.verify(presentation, {
426
+ requiredClaimKeys: ['foo'],
427
+ keyBindingNonce: '342',
428
+ });
429
+ expect(results.kb).toBeDefined();
430
+ });
431
+
272
432
  test('Hasher not found', async () => {
273
433
  const sdjwt = new SDJwtInstance<SdJwtPayload>({});
274
434
  try {
@@ -290,6 +450,38 @@ describe('index', () => {
290
450
  }
291
451
  });
292
452
 
453
+ test('decode rejects invalid _sd_alg values', async () => {
454
+ const { signer, verifier } = createSignerVerifier();
455
+ const sdjwt = new SDJwtInstance<SdJwtPayload>({
456
+ signer,
457
+ signAlg: 'EdDSA',
458
+ verifier,
459
+ hasher: digest,
460
+ saltGenerator: generateSalt,
461
+ });
462
+
463
+ const credential = await sdjwt.issue(
464
+ {
465
+ foo: 'bar',
466
+ iss: 'Issuer',
467
+ iat: Math.floor(Date.now() / 1000),
468
+ vct: '',
469
+ },
470
+ {
471
+ _sd: ['foo'],
472
+ },
473
+ );
474
+
475
+ const parts = credential.split('.');
476
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
477
+ payload._sd_alg = 'SHA-256';
478
+ parts[1] = Buffer.from(JSON.stringify(payload)).toString('base64url');
479
+
480
+ await expect(sdjwt.decode(parts.join('.'))).rejects.toThrow(
481
+ 'Invalid _sd_alg: SHA-256',
482
+ );
483
+ });
484
+
293
485
  test('SaltGenerator not found', async () => {
294
486
  const sdjwt = new SDJwtInstance<SdJwtPayload>({
295
487
  hasher: digest,