@sd-jwt/core 0.19.1-next.5 → 0.19.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/README.md +5 -7
- package/dist/index.d.mts +343 -16
- package/dist/index.d.ts +343 -16
- package/dist/index.js +644 -142
- package/dist/index.mjs +585 -127
- package/package.json +4 -7
- package/src/decode/decode.ts +366 -0
- package/src/decode/index.ts +1 -0
- package/src/decoy.ts +2 -2
- package/src/flattenJSON.ts +3 -3
- package/src/generalJSON.ts +3 -3
- package/src/index.ts +39 -25
- package/src/jwt.ts +10 -15
- package/src/kbjwt.ts +5 -6
- package/src/present/index.ts +1 -0
- package/src/present/present.ts +210 -0
- package/src/sdjwt.ts +11 -10
- package/src/test/decode/decode.spec.ts +202 -0
- package/src/test/decoy.spec.ts +2 -2
- package/src/test/generalJSON.spec.ts +1 -1
- package/src/test/index.spec.ts +2 -2
- package/src/test/jwt.spec.ts +7 -13
- package/src/test/kbjwt.spec.ts +5 -5
- package/src/test/present/present.spec.ts +305 -0
- package/src/test/sdjwt.spec.ts +4 -4
- package/src/test/types/type.spec.ts +88 -0
- package/src/test/utils/base64url.spec.ts +33 -0
- package/src/test/utils/disclosure.spec.ts +170 -0
- package/src/test/utils/error.spec.ts +15 -0
- package/src/types/index.ts +2 -0
- package/src/types/type.ts +249 -0
- package/src/types/verification-error.ts +55 -0
- package/src/utils/base64url.ts +6 -0
- package/src/utils/disclosure.ts +98 -0
- package/src/utils/error.ts +25 -0
- package/src/utils/index.ts +3 -0
- package/test/app-e2e.spec.ts +8 -8
- package/test/rfc9901-validation.spec.ts +150 -0
- package/CHANGELOG.md +0 -240
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
export const SD_SEPARATOR = '~';
|
|
2
|
+
export const SD_LIST_KEY = '...';
|
|
3
|
+
export const SD_DIGEST = '_sd';
|
|
4
|
+
export const SD_DECOY = '_sd_decoy';
|
|
5
|
+
export const KB_JWT_TYP = 'kb+jwt';
|
|
6
|
+
|
|
7
|
+
export type SDJWTCompact = string;
|
|
8
|
+
export type Base64urlString = string;
|
|
9
|
+
|
|
10
|
+
export type DisclosureData<T> = [string, string, T] | [string, T];
|
|
11
|
+
|
|
12
|
+
// based on https://www.iana.org/assignments/named-information/named-information.xhtml
|
|
13
|
+
export const IANA_HASH_ALGORITHMS = [
|
|
14
|
+
'sha-256',
|
|
15
|
+
'sha-256-128',
|
|
16
|
+
'sha-256-120',
|
|
17
|
+
'sha-256-96',
|
|
18
|
+
'sha-256-64',
|
|
19
|
+
'sha-256-32',
|
|
20
|
+
'sha-384',
|
|
21
|
+
'sha-512',
|
|
22
|
+
'sha3-224',
|
|
23
|
+
'sha3-256',
|
|
24
|
+
'sha3-384',
|
|
25
|
+
'sha3-512',
|
|
26
|
+
'blake2s-256',
|
|
27
|
+
'blake2b-256',
|
|
28
|
+
'blake2b-512',
|
|
29
|
+
'k12-256',
|
|
30
|
+
'k12-512',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type HashAlgorithm = (typeof IANA_HASH_ALGORITHMS)[number];
|
|
34
|
+
|
|
35
|
+
export type SDJWTConfig<T = unknown> = {
|
|
36
|
+
omitTyp?: boolean;
|
|
37
|
+
hasher?: Hasher;
|
|
38
|
+
hashAlg?: HashAlgorithm;
|
|
39
|
+
saltGenerator?: SaltGenerator;
|
|
40
|
+
signer?: Signer;
|
|
41
|
+
signAlg?: string;
|
|
42
|
+
verifier?: Verifier<T>;
|
|
43
|
+
kbSigner?: Signer;
|
|
44
|
+
kbSignAlg?: string;
|
|
45
|
+
kbVerifier?: KbVerifier;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type kbHeader = { typ: 'kb+jwt'; alg: string };
|
|
49
|
+
export type kbPayload = {
|
|
50
|
+
iat: number;
|
|
51
|
+
aud: string;
|
|
52
|
+
nonce: string;
|
|
53
|
+
sd_hash: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type KBOptions = {
|
|
57
|
+
payload: Omit<kbPayload, 'sd_hash'>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// This type declaration is from lib.dom.ts
|
|
61
|
+
interface RsaOtherPrimesInfo {
|
|
62
|
+
d?: string;
|
|
63
|
+
r?: string;
|
|
64
|
+
t?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface JsonWebKey {
|
|
68
|
+
alg?: string;
|
|
69
|
+
crv?: string;
|
|
70
|
+
d?: string;
|
|
71
|
+
dp?: string;
|
|
72
|
+
dq?: string;
|
|
73
|
+
e?: string;
|
|
74
|
+
ext?: boolean;
|
|
75
|
+
k?: string;
|
|
76
|
+
key_ops?: string[];
|
|
77
|
+
kty?: string;
|
|
78
|
+
n?: string;
|
|
79
|
+
oth?: RsaOtherPrimesInfo[];
|
|
80
|
+
p?: string;
|
|
81
|
+
q?: string;
|
|
82
|
+
qi?: string;
|
|
83
|
+
use?: string;
|
|
84
|
+
x?: string;
|
|
85
|
+
y?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface JwtPayload {
|
|
89
|
+
cnf?: {
|
|
90
|
+
jwk: JsonWebKey;
|
|
91
|
+
};
|
|
92
|
+
exp?: number;
|
|
93
|
+
[key: string]: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type OrPromise<T> = T | Promise<T>;
|
|
97
|
+
|
|
98
|
+
export type Signer = (data: string) => OrPromise<string>;
|
|
99
|
+
export type Verifier<T = unknown> = (
|
|
100
|
+
data: string,
|
|
101
|
+
sig: string,
|
|
102
|
+
options?: T,
|
|
103
|
+
) => OrPromise<boolean>;
|
|
104
|
+
export type KbVerifier = (
|
|
105
|
+
data: string,
|
|
106
|
+
sig: string,
|
|
107
|
+
payload: JwtPayload,
|
|
108
|
+
) => OrPromise<boolean>;
|
|
109
|
+
export type Hasher = (
|
|
110
|
+
data: string | ArrayBuffer,
|
|
111
|
+
alg: string,
|
|
112
|
+
) => OrPromise<Uint8Array>;
|
|
113
|
+
export type SaltGenerator = (length: number) => OrPromise<string>;
|
|
114
|
+
export type HasherAndAlg = {
|
|
115
|
+
hasher: Hasher;
|
|
116
|
+
alg: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// This functions are sync versions
|
|
120
|
+
export type SignerSync = (data: string) => string;
|
|
121
|
+
export type VerifierSync = (data: string, sig: string) => boolean;
|
|
122
|
+
export type HasherSync = (data: string, alg: string) => Uint8Array;
|
|
123
|
+
export type SaltGeneratorSync = (length: number) => string;
|
|
124
|
+
export type HasherAndAlgSync = {
|
|
125
|
+
hasher: HasherSync;
|
|
126
|
+
alg: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type NonNever<T> = {
|
|
130
|
+
[P in keyof T as T[P] extends never ? never : P]: T[P];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type SD<Payload> = { [SD_DIGEST]?: Array<keyof Payload> };
|
|
134
|
+
export type DECOY = { [SD_DECOY]?: number };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* This is a disclosureFrame type that is used to represent the structure of what is being disclosed.
|
|
138
|
+
* DisclosureFrame is made from the payload type.
|
|
139
|
+
*
|
|
140
|
+
* For example, if the payload is
|
|
141
|
+
* {
|
|
142
|
+
* foo: 'bar',
|
|
143
|
+
* test: {
|
|
144
|
+
* zzz: 'yyy',
|
|
145
|
+
* }
|
|
146
|
+
* arr: ['1', '2', {a: 'b'}]
|
|
147
|
+
* }
|
|
148
|
+
*
|
|
149
|
+
* The disclosureFrame can be subset of:
|
|
150
|
+
* {
|
|
151
|
+
* _sd: ["foo", "test", "arr"],
|
|
152
|
+
* test: {
|
|
153
|
+
* _sd: ["zzz"],
|
|
154
|
+
* },
|
|
155
|
+
* arr: {
|
|
156
|
+
* _sd: ["0", "1", "2"],
|
|
157
|
+
* "2": {
|
|
158
|
+
* _sd: ["a"],
|
|
159
|
+
* }
|
|
160
|
+
* }
|
|
161
|
+
* }
|
|
162
|
+
*
|
|
163
|
+
* The disclosureFrame can be used with decoy.
|
|
164
|
+
* Decoy can be used like this:
|
|
165
|
+
* {
|
|
166
|
+
* ...
|
|
167
|
+
* _sd: ...
|
|
168
|
+
* _sd_decoy: 1 // number of decoy in this layer
|
|
169
|
+
* }
|
|
170
|
+
*
|
|
171
|
+
*/
|
|
172
|
+
type Frame<Payload> =
|
|
173
|
+
Payload extends Array<infer U>
|
|
174
|
+
? U extends object
|
|
175
|
+
? Record<number, Frame<U>> & SD<Payload> & DECOY
|
|
176
|
+
: SD<Payload> & DECOY
|
|
177
|
+
: Payload extends Record<string, unknown>
|
|
178
|
+
? NonNever<
|
|
179
|
+
{
|
|
180
|
+
[K in keyof Payload]?: NonNullable<Payload[K]> extends object
|
|
181
|
+
? Frame<Payload[K]>
|
|
182
|
+
: never;
|
|
183
|
+
} & SD<Payload> &
|
|
184
|
+
DECOY
|
|
185
|
+
>
|
|
186
|
+
: SD<Payload> & DECOY;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* This is a disclosureFrame type that is used to represent the structure of what is being disclosed.
|
|
190
|
+
*/
|
|
191
|
+
export type Extensible = Record<string, unknown | boolean>;
|
|
192
|
+
|
|
193
|
+
export type DisclosureFrame<T extends Extensible> = Frame<T>;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* This is a presentationFrame type that is used to represent the structure of what is being presented.
|
|
197
|
+
* PresentationFrame is made from the payload type.
|
|
198
|
+
* const claims = {
|
|
199
|
+
firstname: 'John',
|
|
200
|
+
lastname: 'Doe',
|
|
201
|
+
ssn: '123-45-6789',
|
|
202
|
+
id: '1234',
|
|
203
|
+
data: {
|
|
204
|
+
firstname: 'John',
|
|
205
|
+
lastname: 'Doe',
|
|
206
|
+
ssn: '123-45-6789',
|
|
207
|
+
list: [{ r: 'd' }, 'b', 'c'],
|
|
208
|
+
list2: ['1', '2', '3'],
|
|
209
|
+
list3: ['1', null, 2],
|
|
210
|
+
},
|
|
211
|
+
data2: {
|
|
212
|
+
hi: 'bye',
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
Example of a presentationFrame:
|
|
217
|
+
const presentationFrame: PresentationFrame<typeof claims> = {
|
|
218
|
+
firstname: true,
|
|
219
|
+
lastname: true,
|
|
220
|
+
ssn: true,
|
|
221
|
+
id: 'true',
|
|
222
|
+
data: {
|
|
223
|
+
firstname: true,
|
|
224
|
+
list: {
|
|
225
|
+
1: true,
|
|
226
|
+
0: {
|
|
227
|
+
r: true,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
list2: {
|
|
231
|
+
1: true,
|
|
232
|
+
},
|
|
233
|
+
list3: true,
|
|
234
|
+
},
|
|
235
|
+
data2: true,
|
|
236
|
+
};
|
|
237
|
+
*/
|
|
238
|
+
type PFrame<Payload> =
|
|
239
|
+
Payload extends Array<infer U>
|
|
240
|
+
? U extends object
|
|
241
|
+
? Record<number, PFrame<U> | boolean> | boolean
|
|
242
|
+
: Record<number, boolean> | boolean
|
|
243
|
+
: {
|
|
244
|
+
[K in keyof Payload]?: NonNullable<Payload[K]> extends object
|
|
245
|
+
? PFrame<Payload[K]> | boolean
|
|
246
|
+
: boolean;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export type PresentationFrame<T extends Extensible> = PFrame<T>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error codes for SD-JWT verification errors.
|
|
3
|
+
*/
|
|
4
|
+
export type VerificationErrorCode =
|
|
5
|
+
| 'HASHER_NOT_FOUND'
|
|
6
|
+
| 'VERIFIER_NOT_FOUND'
|
|
7
|
+
| 'INVALID_SD_JWT'
|
|
8
|
+
| 'INVALID_JWT_FORMAT'
|
|
9
|
+
| 'JWT_NOT_YET_VALID'
|
|
10
|
+
| 'JWT_EXPIRED'
|
|
11
|
+
| 'INVALID_JWT_SIGNATURE'
|
|
12
|
+
| 'MISSING_REQUIRED_CLAIMS'
|
|
13
|
+
| 'KEY_BINDING_JWT_MISSING'
|
|
14
|
+
| 'KEY_BINDING_VERIFIER_NOT_FOUND'
|
|
15
|
+
| 'KEY_BINDING_SIGNATURE_INVALID'
|
|
16
|
+
| 'KEY_BINDING_SD_HASH_INVALID'
|
|
17
|
+
| 'STATUS_VERIFICATION_FAILED'
|
|
18
|
+
| 'STATUS_INVALID'
|
|
19
|
+
| 'VCT_VERIFICATION_FAILED'
|
|
20
|
+
| 'UNKNOWN_ERROR';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents a single verification error.
|
|
24
|
+
*/
|
|
25
|
+
export type VerificationError = {
|
|
26
|
+
/**
|
|
27
|
+
* The error code identifying the type of error.
|
|
28
|
+
*/
|
|
29
|
+
code: VerificationErrorCode;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Human-readable error message.
|
|
33
|
+
*/
|
|
34
|
+
message: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Optional additional details about the error.
|
|
38
|
+
*/
|
|
39
|
+
details?: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Result type for safe verification that collects all errors.
|
|
44
|
+
*/
|
|
45
|
+
export type SafeVerifyResult<T> =
|
|
46
|
+
| {
|
|
47
|
+
success: true;
|
|
48
|
+
data: T;
|
|
49
|
+
errors?: never;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
success: false;
|
|
53
|
+
data?: never;
|
|
54
|
+
errors: VerificationError[];
|
|
55
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { DisclosureData, HasherAndAlg, HasherAndAlgSync } from '../types';
|
|
2
|
+
import {
|
|
3
|
+
base64urlDecode,
|
|
4
|
+
base64urlEncode,
|
|
5
|
+
uint8ArrayToBase64Url,
|
|
6
|
+
} from './base64url';
|
|
7
|
+
import { SDJWTException } from './error';
|
|
8
|
+
|
|
9
|
+
export class Disclosure<T = unknown> {
|
|
10
|
+
public salt: string;
|
|
11
|
+
public key?: string;
|
|
12
|
+
public value: T;
|
|
13
|
+
public _digest: string | undefined;
|
|
14
|
+
private _encoded: string | undefined;
|
|
15
|
+
|
|
16
|
+
public constructor(
|
|
17
|
+
data: DisclosureData<T>,
|
|
18
|
+
_meta?: { digest: string; encoded: string },
|
|
19
|
+
) {
|
|
20
|
+
// If the meta is provided, then we assume that the data is already encoded and digested
|
|
21
|
+
this._digest = _meta?.digest;
|
|
22
|
+
this._encoded = _meta?.encoded;
|
|
23
|
+
|
|
24
|
+
if (data.length === 2) {
|
|
25
|
+
this.salt = data[0];
|
|
26
|
+
this.value = data[1];
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (data.length === 3) {
|
|
30
|
+
this.salt = data[0];
|
|
31
|
+
this.key = data[1];
|
|
32
|
+
this.value = data[2];
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
throw new SDJWTException('Invalid disclosure data');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// We need to digest of the original encoded data.
|
|
39
|
+
// After decode process, we use JSON.stringify to encode the data.
|
|
40
|
+
// This can be different from the original encoded data.
|
|
41
|
+
public static async fromEncode<T>(s: string, hash: HasherAndAlg) {
|
|
42
|
+
const { hasher, alg } = hash;
|
|
43
|
+
const digest = await hasher(s, alg);
|
|
44
|
+
const digestStr = uint8ArrayToBase64Url(digest);
|
|
45
|
+
const item = JSON.parse(base64urlDecode(s)) as DisclosureData<T>;
|
|
46
|
+
return Disclosure.fromArray<T>(item, { digest: digestStr, encoded: s });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public static fromEncodeSync<T>(s: string, hash: HasherAndAlgSync) {
|
|
50
|
+
const { hasher, alg } = hash;
|
|
51
|
+
const digest = hasher(s, alg);
|
|
52
|
+
const digestStr = uint8ArrayToBase64Url(digest);
|
|
53
|
+
const item = JSON.parse(base64urlDecode(s)) as DisclosureData<T>;
|
|
54
|
+
return Disclosure.fromArray<T>(item, { digest: digestStr, encoded: s });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static fromArray<T>(
|
|
58
|
+
item: DisclosureData<T>,
|
|
59
|
+
_meta?: { digest: string; encoded: string },
|
|
60
|
+
) {
|
|
61
|
+
return new Disclosure(item, _meta);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public encode() {
|
|
65
|
+
if (!this._encoded) {
|
|
66
|
+
// we use JSON.stringify to encode the data
|
|
67
|
+
// It's the most reliable and universal way to encode JSON object
|
|
68
|
+
this._encoded = base64urlEncode(JSON.stringify(this.decode()));
|
|
69
|
+
}
|
|
70
|
+
return this._encoded;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public decode(): DisclosureData<T> {
|
|
74
|
+
return this.key
|
|
75
|
+
? [this.salt, this.key, this.value]
|
|
76
|
+
: [this.salt, this.value];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public async digest(hash: HasherAndAlg): Promise<string> {
|
|
80
|
+
const { hasher, alg } = hash;
|
|
81
|
+
if (!this._digest) {
|
|
82
|
+
const hash = await hasher(this.encode(), alg);
|
|
83
|
+
this._digest = uint8ArrayToBase64Url(hash);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this._digest;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public digestSync(hash: HasherAndAlgSync): string {
|
|
90
|
+
const { hasher, alg } = hash;
|
|
91
|
+
if (!this._digest) {
|
|
92
|
+
const hash = hasher(this.encode(), alg);
|
|
93
|
+
this._digest = uint8ArrayToBase64Url(hash);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this._digest;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class SDJWTException extends Error {
|
|
2
|
+
public details?: unknown;
|
|
3
|
+
|
|
4
|
+
constructor(message: string, details?: unknown) {
|
|
5
|
+
super(message);
|
|
6
|
+
Object.setPrototypeOf(this, SDJWTException.prototype);
|
|
7
|
+
this.name = 'SDJWTException';
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getFullMessage(): string {
|
|
12
|
+
return `${this.name}: ${this.message} ${
|
|
13
|
+
this.details ? `- ${JSON.stringify(this.details)}` : ''
|
|
14
|
+
}`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Narrows an unknown caught value to an Error instance.
|
|
20
|
+
*/
|
|
21
|
+
export function ensureError(value: unknown): Error {
|
|
22
|
+
if (value instanceof Error) return value;
|
|
23
|
+
if (typeof value === 'string') return new Error(value);
|
|
24
|
+
return new Error(String(value));
|
|
25
|
+
}
|
package/test/app-e2e.spec.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import Crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { digest, generateSalt } from '@
|
|
5
|
-
import type {
|
|
6
|
-
DisclosureFrame,
|
|
7
|
-
PresentationFrame,
|
|
8
|
-
Signer,
|
|
9
|
-
Verifier,
|
|
10
|
-
} from '@sd-jwt/types';
|
|
4
|
+
import { hasher as digest, generateSalt, type Signer } from '@owf/crypto';
|
|
11
5
|
import { describe, expect, test } from 'vitest';
|
|
12
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type DisclosureFrame,
|
|
8
|
+
type PresentationFrame,
|
|
9
|
+
SDJwtInstance,
|
|
10
|
+
type SdJwtPayload,
|
|
11
|
+
type Verifier,
|
|
12
|
+
} from '../src';
|
|
13
13
|
|
|
14
14
|
const createSignerVerifier = () => {
|
|
15
15
|
const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { unpackObj } from '../src/decode';
|
|
3
|
+
import { Disclosure } from '../src/utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for RFC 9901 validation checks added to unpackObj / unpackObjInternal.
|
|
7
|
+
*
|
|
8
|
+
* Section 7.1 step 4: Duplicate digest rejection
|
|
9
|
+
* Section 7.1 step 5: Unreferenced disclosure rejection
|
|
10
|
+
* Section 7.1 step 3c.ii.3: Claim name collision rejection
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const makeDisclosure = (
|
|
14
|
+
digest: string,
|
|
15
|
+
key: string | undefined,
|
|
16
|
+
value: unknown,
|
|
17
|
+
) =>
|
|
18
|
+
Disclosure.fromArray(key ? ['salt', key, value] : ['salt', value], {
|
|
19
|
+
digest,
|
|
20
|
+
encoded: `encoded-${digest}`,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('RFC 9901 validation', () => {
|
|
24
|
+
// ──────────────────────────────────────────
|
|
25
|
+
// 7.1 step 4 — Duplicate digest in _sd array
|
|
26
|
+
// ──────────────────────────────────────────
|
|
27
|
+
test('rejects duplicate digest in _sd array', () => {
|
|
28
|
+
const digest = 'abc123';
|
|
29
|
+
const payload = { _sd: [digest, digest] };
|
|
30
|
+
const map: Record<string, Disclosure> = {
|
|
31
|
+
[digest]: makeDisclosure(digest, 'foo', 'bar'),
|
|
32
|
+
};
|
|
33
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
34
|
+
'Duplicate digest found in SD-JWT payload',
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ──────────────────────────────────────────
|
|
39
|
+
// 7.1 step 4 — Duplicate digest across nested _sd
|
|
40
|
+
// ──────────────────────────────────────────
|
|
41
|
+
test('rejects duplicate digest across nested _sd arrays', () => {
|
|
42
|
+
const digest = 'dup1';
|
|
43
|
+
const payload = {
|
|
44
|
+
_sd: [digest],
|
|
45
|
+
nested: {
|
|
46
|
+
_sd: [digest],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const map: Record<string, Disclosure> = {
|
|
50
|
+
[digest]: makeDisclosure(digest, 'x', 'y'),
|
|
51
|
+
};
|
|
52
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
53
|
+
'Duplicate digest found in SD-JWT payload',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ──────────────────────────────────────────
|
|
58
|
+
// 7.1 step 4 — Duplicate digest in array items
|
|
59
|
+
// ──────────────────────────────────────────
|
|
60
|
+
test('rejects duplicate digest in array element disclosures', () => {
|
|
61
|
+
const digest = 'arrdup';
|
|
62
|
+
const payload = {
|
|
63
|
+
arr: [{ '...': digest }, { '...': digest }],
|
|
64
|
+
};
|
|
65
|
+
const map: Record<string, Disclosure> = {
|
|
66
|
+
[digest]: makeDisclosure(digest, undefined, 'val'),
|
|
67
|
+
};
|
|
68
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
69
|
+
'Duplicate digest found in SD-JWT payload',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ──────────────────────────────────────────
|
|
74
|
+
// 7.1 step 5 — Unreferenced disclosure
|
|
75
|
+
// ──────────────────────────────────────────
|
|
76
|
+
test('rejects unreferenced disclosure', () => {
|
|
77
|
+
const usedDigest = 'used1';
|
|
78
|
+
const unusedDigest = 'unused1';
|
|
79
|
+
const payload = { _sd: [usedDigest] };
|
|
80
|
+
const map: Record<string, Disclosure> = {
|
|
81
|
+
[usedDigest]: makeDisclosure(usedDigest, 'a', 1),
|
|
82
|
+
[unusedDigest]: makeDisclosure(unusedDigest, 'b', 2),
|
|
83
|
+
};
|
|
84
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
85
|
+
'Unreferenced disclosure(s) detected in SD-JWT',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('rejects when no digests exist in payload but disclosures provided', () => {
|
|
90
|
+
const payload = { plain: 'value' };
|
|
91
|
+
const map: Record<string, Disclosure> = {
|
|
92
|
+
orphan: makeDisclosure('orphan', 'k', 'v'),
|
|
93
|
+
};
|
|
94
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
95
|
+
'Unreferenced disclosure(s) detected in SD-JWT',
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ──────────────────────────────────────────
|
|
100
|
+
// 7.1 step 3c.ii.3 — Claim name collision
|
|
101
|
+
// ──────────────────────────────────────────
|
|
102
|
+
test('rejects disclosed claim name that conflicts with plaintext key', () => {
|
|
103
|
+
const digest = 'col1';
|
|
104
|
+
// The payload has both a plaintext "name" and a disclosure that would add "name"
|
|
105
|
+
const payload = { name: 'Alice', _sd: [digest] };
|
|
106
|
+
const map: Record<string, Disclosure> = {
|
|
107
|
+
[digest]: makeDisclosure(digest, 'name', 'Mallory'),
|
|
108
|
+
};
|
|
109
|
+
expect(() => unpackObj(payload, map)).toThrow(
|
|
110
|
+
'Disclosed claim name "name" conflicts with existing payload key',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ──────────────────────────────────────────
|
|
115
|
+
// Positive: valid SD-JWT unpacks without error
|
|
116
|
+
// ──────────────────────────────────────────
|
|
117
|
+
test('unpacks valid SD-JWT without errors', () => {
|
|
118
|
+
const d1 = 'digest1';
|
|
119
|
+
const d2 = 'digest2';
|
|
120
|
+
const payload = { _sd: [d1, d2], plain: 'hello' };
|
|
121
|
+
const map: Record<string, Disclosure> = {
|
|
122
|
+
[d1]: makeDisclosure(d1, 'foo', 'bar'),
|
|
123
|
+
[d2]: makeDisclosure(d2, 'baz', 42),
|
|
124
|
+
};
|
|
125
|
+
const { unpackedObj, disclosureKeymap } = unpackObj(payload, map);
|
|
126
|
+
expect(unpackedObj).toEqual({ plain: 'hello', foo: 'bar', baz: 42 });
|
|
127
|
+
expect(disclosureKeymap).toEqual({ foo: d1, baz: d2 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('unpacks valid SD-JWT with array disclosures', () => {
|
|
131
|
+
const d1 = 'arrdig1';
|
|
132
|
+
const d2 = 'arrdig2';
|
|
133
|
+
const payload = {
|
|
134
|
+
arr: [{ '...': d1 }, 'plainItem', { '...': d2 }],
|
|
135
|
+
};
|
|
136
|
+
const map: Record<string, Disclosure> = {
|
|
137
|
+
[d1]: makeDisclosure(d1, undefined, 'secret1'),
|
|
138
|
+
[d2]: makeDisclosure(d2, undefined, 'secret2'),
|
|
139
|
+
};
|
|
140
|
+
const { unpackedObj } = unpackObj(payload, map);
|
|
141
|
+
expect(unpackedObj).toEqual({ arr: ['secret1', 'plainItem', 'secret2'] });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('allows empty disclosure map with no _sd in payload', () => {
|
|
145
|
+
const payload = { plain: 'value' };
|
|
146
|
+
const map: Record<string, Disclosure> = {};
|
|
147
|
+
const { unpackedObj } = unpackObj(payload, map);
|
|
148
|
+
expect(unpackedObj).toEqual({ plain: 'value' });
|
|
149
|
+
});
|
|
150
|
+
});
|