qredential 0.2.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.
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/dist/base45.d.ts +11 -0
- package/dist/base45.js +62 -0
- package/dist/bytes.d.ts +10 -0
- package/dist/bytes.js +73 -0
- package/dist/compress.d.ts +8 -0
- package/dist/compress.js +56 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +72 -0
- package/dist/duration.d.ts +3 -0
- package/dist/duration.js +24 -0
- package/dist/envelope.d.ts +12 -0
- package/dist/envelope.js +57 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +461 -0
- package/dist/qr.d.ts +36 -0
- package/dist/qr.js +59 -0
- package/dist/sdjwt.d.ts +85 -0
- package/dist/sdjwt.js +310 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.js +122 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +1 -0
- package/package.json +81 -0
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character capacity of a QR code in alphanumeric mode, versions 1 to 40, per ISO/IEC 18004.
|
|
3
|
+
*
|
|
4
|
+
* base45 output is alphanumeric-safe by construction, which is the reason to use it. Worth being
|
|
5
|
+
* precise about what that buys, because the folklore overstates it: base45 in alphanumeric mode
|
|
6
|
+
* costs about 8.25 bits per original byte, against 10.67 for base64 in byte mode, and 8 flat for
|
|
7
|
+
* raw binary in byte mode. So it beats base64 clearly and loses slightly to raw bytes. Raw bytes
|
|
8
|
+
* are given up on purpose: byte mode carries charset ambiguity, and plenty of scanners hand back a
|
|
9
|
+
* mangled string. A credential that survives being copied, pasted and logged is worth a 3% size
|
|
10
|
+
* penalty.
|
|
11
|
+
*/
|
|
12
|
+
declare const CAPACITY: {
|
|
13
|
+
readonly L: readonly [25, 47, 77, 114, 154, 195, 224, 279, 335, 395, 468, 535, 619, 667, 758, 854, 938, 1046, 1153, 1249, 1352, 1460, 1588, 1704, 1853, 1990, 2132, 2223, 2369, 2520, 2677, 2840, 3009, 3183, 3351, 3537, 3729, 3927, 4087, 4296];
|
|
14
|
+
readonly M: readonly [20, 38, 61, 90, 122, 154, 178, 221, 262, 311, 366, 419, 483, 528, 600, 656, 734, 816, 909, 970, 1035, 1134, 1248, 1326, 1451, 1542, 1637, 1732, 1839, 1994, 2113, 2238, 2369, 2506, 2632, 2780, 2894, 3054, 3220, 3391];
|
|
15
|
+
readonly Q: readonly [16, 29, 47, 67, 87, 108, 125, 157, 189, 221, 259, 296, 352, 376, 426, 470, 531, 574, 644, 702, 742, 823, 890, 963, 1041, 1094, 1172, 1263, 1322, 1429, 1499, 1618, 1700, 1787, 1867, 1966, 2071, 2181, 2298, 2420];
|
|
16
|
+
readonly H: readonly [10, 20, 35, 50, 64, 84, 93, 122, 143, 174, 200, 227, 259, 283, 321, 365, 408, 452, 493, 557, 587, 640, 672, 744, 779, 864, 910, 958, 1016, 1080, 1150, 1226, 1307, 1394, 1431, 1530, 1591, 1658, 1774, 1852];
|
|
17
|
+
};
|
|
18
|
+
export type ErrorCorrection = keyof typeof CAPACITY;
|
|
19
|
+
export interface FitResult {
|
|
20
|
+
chars: number;
|
|
21
|
+
/** Smallest QR version that holds the payload, or null when nothing does. */
|
|
22
|
+
version: number | null;
|
|
23
|
+
capacity: number | null;
|
|
24
|
+
/** True when it fits at a version that scans reliably on a phone in bad light. */
|
|
25
|
+
comfortable: boolean;
|
|
26
|
+
errorCorrection: ErrorCorrection;
|
|
27
|
+
advice: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Check a payload against real QR limits before you design a credential you cannot print.
|
|
31
|
+
*
|
|
32
|
+
* Error correction level M is the default because that is what almost every real deployment uses:
|
|
33
|
+
* L looks generous on paper and then fails on a scuffed printed card.
|
|
34
|
+
*/
|
|
35
|
+
export declare function fits(payload: string, errorCorrection?: ErrorCorrection): FitResult;
|
|
36
|
+
export {};
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character capacity of a QR code in alphanumeric mode, versions 1 to 40, per ISO/IEC 18004.
|
|
3
|
+
*
|
|
4
|
+
* base45 output is alphanumeric-safe by construction, which is the reason to use it. Worth being
|
|
5
|
+
* precise about what that buys, because the folklore overstates it: base45 in alphanumeric mode
|
|
6
|
+
* costs about 8.25 bits per original byte, against 10.67 for base64 in byte mode, and 8 flat for
|
|
7
|
+
* raw binary in byte mode. So it beats base64 clearly and loses slightly to raw bytes. Raw bytes
|
|
8
|
+
* are given up on purpose: byte mode carries charset ambiguity, and plenty of scanners hand back a
|
|
9
|
+
* mangled string. A credential that survives being copied, pasted and logged is worth a 3% size
|
|
10
|
+
* penalty.
|
|
11
|
+
*/
|
|
12
|
+
const CAPACITY = {
|
|
13
|
+
L: [25, 47, 77, 114, 154, 195, 224, 279, 335, 395, 468, 535, 619, 667, 758, 854, 938, 1046, 1153, 1249, 1352, 1460, 1588, 1704, 1853, 1990, 2132, 2223, 2369, 2520, 2677, 2840, 3009, 3183, 3351, 3537, 3729, 3927, 4087, 4296],
|
|
14
|
+
M: [20, 38, 61, 90, 122, 154, 178, 221, 262, 311, 366, 419, 483, 528, 600, 656, 734, 816, 909, 970, 1035, 1134, 1248, 1326, 1451, 1542, 1637, 1732, 1839, 1994, 2113, 2238, 2369, 2506, 2632, 2780, 2894, 3054, 3220, 3391],
|
|
15
|
+
Q: [16, 29, 47, 67, 87, 108, 125, 157, 189, 221, 259, 296, 352, 376, 426, 470, 531, 574, 644, 702, 742, 823, 890, 963, 1041, 1094, 1172, 1263, 1322, 1429, 1499, 1618, 1700, 1787, 1867, 1966, 2071, 2181, 2298, 2420],
|
|
16
|
+
H: [10, 20, 35, 50, 64, 84, 93, 122, 143, 174, 200, 227, 259, 283, 321, 365, 408, 452, 493, 557, 587, 640, 672, 744, 779, 864, 910, 958, 1016, 1080, 1150, 1226, 1307, 1394, 1431, 1530, 1591, 1658, 1774, 1852],
|
|
17
|
+
};
|
|
18
|
+
/** Above this version the modules get small enough that cheap cameras and cracked screens struggle. */
|
|
19
|
+
const COMFORTABLE_VERSION = 20;
|
|
20
|
+
/**
|
|
21
|
+
* Check a payload against real QR limits before you design a credential you cannot print.
|
|
22
|
+
*
|
|
23
|
+
* Error correction level M is the default because that is what almost every real deployment uses:
|
|
24
|
+
* L looks generous on paper and then fails on a scuffed printed card.
|
|
25
|
+
*/
|
|
26
|
+
export function fits(payload, errorCorrection = 'M') {
|
|
27
|
+
const table = CAPACITY[errorCorrection];
|
|
28
|
+
const chars = payload.length;
|
|
29
|
+
let version = null;
|
|
30
|
+
let capacity = null;
|
|
31
|
+
for (let i = 0; i < table.length; i++) {
|
|
32
|
+
if (chars <= table[i]) {
|
|
33
|
+
version = i + 1;
|
|
34
|
+
capacity = table[i];
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (version === null) {
|
|
39
|
+
return {
|
|
40
|
+
chars,
|
|
41
|
+
version: null,
|
|
42
|
+
capacity: null,
|
|
43
|
+
comfortable: false,
|
|
44
|
+
errorCorrection,
|
|
45
|
+
advice: `${chars} characters does not fit in any QR code at level ${errorCorrection}. Move claims out of the credential or make them selectively disclosable.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const comfortable = version <= COMFORTABLE_VERSION;
|
|
49
|
+
return {
|
|
50
|
+
chars,
|
|
51
|
+
version,
|
|
52
|
+
capacity,
|
|
53
|
+
comfortable,
|
|
54
|
+
errorCorrection,
|
|
55
|
+
advice: comfortable
|
|
56
|
+
? `Fits QR version ${version} at level ${errorCorrection}, with ${capacity - chars} characters to spare.`
|
|
57
|
+
: `Fits QR version ${version}, which is dense enough that scanning gets unreliable on worn cards and cheap cameras. Aim for version ${COMFORTABLE_VERSION} or below.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/sdjwt.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export declare const SEPARATOR = "~";
|
|
2
|
+
/**
|
|
3
|
+
* Registered claims that describe the token rather than the subject.
|
|
4
|
+
*
|
|
5
|
+
* They are kept out of `claims` because every one of them is already surfaced as a typed field on
|
|
6
|
+
* the result. Leaving them mixed in means `Object.keys(result.claims)` hands a caller `iat` and
|
|
7
|
+
* `status` alongside `over_18`, which makes the obvious loop over a person's attributes wrong.
|
|
8
|
+
*/
|
|
9
|
+
export declare const REGISTERED_CLAIMS: Set<string>;
|
|
10
|
+
export interface Disclosure {
|
|
11
|
+
/** The transmitted string. The digest is taken over exactly these characters. */
|
|
12
|
+
raw: string;
|
|
13
|
+
salt: string;
|
|
14
|
+
/** Present for an object property, absent for an array element. */
|
|
15
|
+
name?: string;
|
|
16
|
+
value: unknown;
|
|
17
|
+
kind: 'property' | 'element';
|
|
18
|
+
}
|
|
19
|
+
export declare function makeDisclosure(name: string, value: unknown): Disclosure;
|
|
20
|
+
/** RFC 9901 section 4.2.2: an array element disclosure carries no claim name. */
|
|
21
|
+
export declare function makeElementDisclosure(value: unknown): Disclosure;
|
|
22
|
+
export declare function parseDisclosure(raw: string): Disclosure;
|
|
23
|
+
/**
|
|
24
|
+
* The hash a key binding JWT commits to.
|
|
25
|
+
*
|
|
26
|
+
* Taken over the whole presentation up to and including the final separator, so the holder's
|
|
27
|
+
* signature covers exactly this set of disclosures. Without it a relay could strip or add
|
|
28
|
+
* disclosures after the holder signed, and the proof would still check out.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sdHash(jwt: string, disclosures: string[]): Promise<string>;
|
|
31
|
+
/** Digest of a disclosure exactly as transmitted. Hashing a re-serialised copy would not match. */
|
|
32
|
+
export declare function digest(raw: string): Promise<string>;
|
|
33
|
+
export declare function splitCombined(combined: string): {
|
|
34
|
+
jwt: string;
|
|
35
|
+
disclosures: string[];
|
|
36
|
+
keyBinding?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function joinCombined(jwt: string, disclosures: string[], keyBinding?: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Rebuild the claim set from the always visible payload plus whichever disclosures travelled.
|
|
41
|
+
*
|
|
42
|
+
* Every disclosure has to match a digest the issuer signed. A disclosure that does not is an
|
|
43
|
+
* attempt to add a claim after the fact, so it fails the whole credential rather than being
|
|
44
|
+
* skipped: a verifier that silently ignores an injected claim is the bug that makes the format
|
|
45
|
+
* pointless.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Rebuild the claim set, following the processing model in RFC 9901 section 7.1.
|
|
49
|
+
*
|
|
50
|
+
* Two shapes of embedded digest exist and both are resolved here, at any depth:
|
|
51
|
+
*
|
|
52
|
+
* - an object with an `_sd` array, whose digests stand for properties of that object
|
|
53
|
+
* - an array element shaped `{"...": digest}`, which stands for the element itself
|
|
54
|
+
*
|
|
55
|
+
* Resolution is recursive, because a disclosed value can itself contain either shape. The rules
|
|
56
|
+
* that reject rather than skip are the ones that matter: a disclosure the issuer never signed, one
|
|
57
|
+
* used twice, one naming a reserved or already-present claim, or one left over at the end. A
|
|
58
|
+
* verifier that silently ignores any of those is the bug that makes the format pointless.
|
|
59
|
+
*/
|
|
60
|
+
export declare function reconstructClaims(payload: Record<string, unknown>, disclosures: string[]): Promise<{
|
|
61
|
+
claims: Record<string, unknown>;
|
|
62
|
+
disclosed: string[];
|
|
63
|
+
withheld: number;
|
|
64
|
+
}>;
|
|
65
|
+
export interface DisclosureLocation {
|
|
66
|
+
/** Where this disclosure's value lands, as a path: `address.locality`, `nationalities[0]`. */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Disclosures that must travel with it, because its digest only appears once they are resolved. */
|
|
69
|
+
requires: string[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Work out where each disclosure sits in the credential.
|
|
73
|
+
*
|
|
74
|
+
* A nested disclosure's path does not exist until its parent is resolved: the digest for
|
|
75
|
+
* `address.locality` lives inside the value of the `address` disclosure, so the walk has to resolve
|
|
76
|
+
* as it goes, exactly the way a verifier does. That is also why each location carries its ancestors.
|
|
77
|
+
* RFC 9901 section 4.2.6 is explicit that sending a nested disclosure without the one containing it
|
|
78
|
+
* is illegal, so a holder who asks for `address.locality` has to send `address` too, and this is
|
|
79
|
+
* what lets present() work that out rather than making the caller do it.
|
|
80
|
+
*
|
|
81
|
+
* Array indices are positions in the credential as issued. They stay stable whatever the holder
|
|
82
|
+
* decides to withhold, which is the only way a selector written against the credential can keep
|
|
83
|
+
* meaning what it said.
|
|
84
|
+
*/
|
|
85
|
+
export declare function disclosureLocations(payload: Record<string, unknown>, disclosures: string[]): Promise<Map<string, DisclosureLocation>>;
|
package/dist/sdjwt.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { b64url, b64urlJson, unb64urlJson, randomBytes, utf8 } from './bytes.js';
|
|
2
|
+
import { sha256 } from './crypto.js';
|
|
3
|
+
import { QredentialError } from './errors.js';
|
|
4
|
+
export const SEPARATOR = '~';
|
|
5
|
+
/**
|
|
6
|
+
* Registered claims that describe the token rather than the subject.
|
|
7
|
+
*
|
|
8
|
+
* They are kept out of `claims` because every one of them is already surfaced as a typed field on
|
|
9
|
+
* the result. Leaving them mixed in means `Object.keys(result.claims)` hands a caller `iat` and
|
|
10
|
+
* `status` alongside `over_18`, which makes the obvious loop over a person's attributes wrong.
|
|
11
|
+
*/
|
|
12
|
+
export const REGISTERED_CLAIMS = new Set([
|
|
13
|
+
'iss', 'iat', 'exp', 'nbf', 'sub', 'vct', 'status', 'cnf', '_sd', '_sd_alg',
|
|
14
|
+
]);
|
|
15
|
+
export function makeDisclosure(name, value) {
|
|
16
|
+
const salt = b64url(randomBytes(16));
|
|
17
|
+
// The array form and its ordering are fixed by RFC 9901: [salt, claim name, claim value].
|
|
18
|
+
const raw = b64urlJson([salt, name, value]);
|
|
19
|
+
return { raw, salt, name, value, kind: 'property' };
|
|
20
|
+
}
|
|
21
|
+
/** RFC 9901 section 4.2.2: an array element disclosure carries no claim name. */
|
|
22
|
+
export function makeElementDisclosure(value) {
|
|
23
|
+
const salt = b64url(randomBytes(16));
|
|
24
|
+
const raw = b64urlJson([salt, value]);
|
|
25
|
+
return { raw, salt, value, kind: 'element' };
|
|
26
|
+
}
|
|
27
|
+
export function parseDisclosure(raw) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = unb64urlJson(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new QredentialError('malformed_credential', 'disclosure is not readable', { cause: error });
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(parsed) || (parsed.length !== 2 && parsed.length !== 3)) {
|
|
36
|
+
throw new QredentialError('malformed_credential', 'disclosure must be an array of two elements (array member) or three (object property)');
|
|
37
|
+
}
|
|
38
|
+
const salt = parsed[0];
|
|
39
|
+
if (typeof salt !== 'string') {
|
|
40
|
+
throw new QredentialError('malformed_credential', 'disclosure salt must be a string');
|
|
41
|
+
}
|
|
42
|
+
if (parsed.length === 2) {
|
|
43
|
+
return { raw, salt, value: parsed[1], kind: 'element' };
|
|
44
|
+
}
|
|
45
|
+
const name = parsed[1];
|
|
46
|
+
if (typeof name !== 'string') {
|
|
47
|
+
throw new QredentialError('malformed_credential', 'disclosure claim name must be a string');
|
|
48
|
+
}
|
|
49
|
+
return { raw, salt, name, value: parsed[2], kind: 'property' };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The hash a key binding JWT commits to.
|
|
53
|
+
*
|
|
54
|
+
* Taken over the whole presentation up to and including the final separator, so the holder's
|
|
55
|
+
* signature covers exactly this set of disclosures. Without it a relay could strip or add
|
|
56
|
+
* disclosures after the holder signed, and the proof would still check out.
|
|
57
|
+
*/
|
|
58
|
+
export async function sdHash(jwt, disclosures) {
|
|
59
|
+
return b64url(await sha256(utf8(joinCombined(jwt, disclosures))));
|
|
60
|
+
}
|
|
61
|
+
/** Digest of a disclosure exactly as transmitted. Hashing a re-serialised copy would not match. */
|
|
62
|
+
export async function digest(raw) {
|
|
63
|
+
return b64url(await sha256(utf8(raw)));
|
|
64
|
+
}
|
|
65
|
+
export function splitCombined(combined) {
|
|
66
|
+
const parts = combined.split(SEPARATOR);
|
|
67
|
+
// A bare JWT is not a combined form. The separator is mandatory even with no disclosures, and
|
|
68
|
+
// accepting its absence is the same leniency the empty segment check below exists to stop.
|
|
69
|
+
if (parts.length < 2) {
|
|
70
|
+
throw new QredentialError('malformed_credential', 'credential is missing the ~ separator that ends every SD-JWT combined form');
|
|
71
|
+
}
|
|
72
|
+
const jwt = parts[0] ?? '';
|
|
73
|
+
const rest = parts.slice(1);
|
|
74
|
+
// The combined form ends with a separator when there is no key binding JWT, which leaves a
|
|
75
|
+
// trailing empty segment. A non-empty final segment is a key binding JWT.
|
|
76
|
+
let keyBinding;
|
|
77
|
+
if (rest.length > 0) {
|
|
78
|
+
const last = rest[rest.length - 1];
|
|
79
|
+
if (last === '')
|
|
80
|
+
rest.pop();
|
|
81
|
+
else {
|
|
82
|
+
keyBinding = last;
|
|
83
|
+
rest.pop();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Everything still in `rest` is a disclosure, and an empty one is not a disclosure. Accepting it
|
|
87
|
+
// quietly would mean this parser and a stricter one disagree about whether the same bytes are a
|
|
88
|
+
// valid credential, which is exactly how parser differentials start.
|
|
89
|
+
if (rest.some((d) => d === '')) {
|
|
90
|
+
throw new QredentialError('malformed_credential', 'credential contains an empty disclosure segment');
|
|
91
|
+
}
|
|
92
|
+
return { jwt, disclosures: rest, keyBinding };
|
|
93
|
+
}
|
|
94
|
+
export function joinCombined(jwt, disclosures, keyBinding) {
|
|
95
|
+
return [jwt, ...disclosures, keyBinding ?? ''].join(SEPARATOR);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Rebuild the claim set from the always visible payload plus whichever disclosures travelled.
|
|
99
|
+
*
|
|
100
|
+
* Every disclosure has to match a digest the issuer signed. A disclosure that does not is an
|
|
101
|
+
* attempt to add a claim after the fact, so it fails the whole credential rather than being
|
|
102
|
+
* skipped: a verifier that silently ignores an injected claim is the bug that makes the format
|
|
103
|
+
* pointless.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* Rebuild the claim set, following the processing model in RFC 9901 section 7.1.
|
|
107
|
+
*
|
|
108
|
+
* Two shapes of embedded digest exist and both are resolved here, at any depth:
|
|
109
|
+
*
|
|
110
|
+
* - an object with an `_sd` array, whose digests stand for properties of that object
|
|
111
|
+
* - an array element shaped `{"...": digest}`, which stands for the element itself
|
|
112
|
+
*
|
|
113
|
+
* Resolution is recursive, because a disclosed value can itself contain either shape. The rules
|
|
114
|
+
* that reject rather than skip are the ones that matter: a disclosure the issuer never signed, one
|
|
115
|
+
* used twice, one naming a reserved or already-present claim, or one left over at the end. A
|
|
116
|
+
* verifier that silently ignores any of those is the bug that makes the format pointless.
|
|
117
|
+
*/
|
|
118
|
+
export async function reconstructClaims(payload, disclosures) {
|
|
119
|
+
const sdAlg = payload['_sd_alg'] ?? 'sha-256';
|
|
120
|
+
if (sdAlg !== 'sha-256')
|
|
121
|
+
throw new QredentialError('unsupported_alg', `unsupported _sd_alg: ${sdAlg}`);
|
|
122
|
+
const byDigest = new Map();
|
|
123
|
+
for (const raw of disclosures) {
|
|
124
|
+
const dig = await digest(raw);
|
|
125
|
+
// Keying by digest would quietly swallow a repeat, and a presentation that sends the same
|
|
126
|
+
// disclosure twice is malformed however harmless it looks.
|
|
127
|
+
if (byDigest.has(dig)) {
|
|
128
|
+
throw new QredentialError('malformed_credential', 'the same disclosure was sent more than once');
|
|
129
|
+
}
|
|
130
|
+
byDigest.set(dig, parseDisclosure(raw));
|
|
131
|
+
}
|
|
132
|
+
const used = new Set();
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const disclosed = [];
|
|
135
|
+
let withheld = 0;
|
|
136
|
+
/** Record a digest the payload embeds, rejecting a second sighting of the same one. */
|
|
137
|
+
const note = (dig) => {
|
|
138
|
+
if (seen.has(dig)) {
|
|
139
|
+
throw new QredentialError('malformed_credential', 'the same digest appears more than once in this credential');
|
|
140
|
+
}
|
|
141
|
+
seen.add(dig);
|
|
142
|
+
};
|
|
143
|
+
const resolveObject = (node, path) => {
|
|
144
|
+
const sd = node['_sd'];
|
|
145
|
+
delete node['_sd'];
|
|
146
|
+
if (sd !== undefined) {
|
|
147
|
+
if (!Array.isArray(sd) || sd.some((d) => typeof d !== 'string')) {
|
|
148
|
+
throw new QredentialError('malformed_credential', '_sd must be an array of strings');
|
|
149
|
+
}
|
|
150
|
+
for (const dig of sd) {
|
|
151
|
+
note(dig);
|
|
152
|
+
const found = byDigest.get(dig);
|
|
153
|
+
// A digest with no disclosure is a claim the holder withheld. It is ignored on purpose:
|
|
154
|
+
// that is what withholding looks like from here.
|
|
155
|
+
if (!found) {
|
|
156
|
+
withheld++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (found.kind !== 'property' || found.name === undefined) {
|
|
160
|
+
throw new QredentialError('malformed_credential', 'a digest under _sd resolved to an array element disclosure');
|
|
161
|
+
}
|
|
162
|
+
if (found.name === '_sd' || found.name === '...') {
|
|
163
|
+
throw new QredentialError('malformed_credential', `disclosure uses the reserved claim name "${found.name}"`);
|
|
164
|
+
}
|
|
165
|
+
if (REGISTERED_CLAIMS.has(found.name)) {
|
|
166
|
+
throw new QredentialError('malformed_credential', `disclosure tries to set the registered claim "${found.name}"`);
|
|
167
|
+
}
|
|
168
|
+
if (Object.prototype.hasOwnProperty.call(node, found.name)) {
|
|
169
|
+
throw new QredentialError('malformed_credential', `disclosure for "${found.name}" collides with a claim already in the payload`);
|
|
170
|
+
}
|
|
171
|
+
used.add(dig);
|
|
172
|
+
node[found.name] = found.value;
|
|
173
|
+
disclosed.push([...path, found.name].join('.'));
|
|
174
|
+
walk(found.value, [...path, found.name]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const [key, value] of Object.entries(node)) {
|
|
178
|
+
walk(value, [...path, key]);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const resolveArray = (node, path) => {
|
|
182
|
+
const kept = [];
|
|
183
|
+
for (let i = 0; i < node.length; i++) {
|
|
184
|
+
const item = node[i];
|
|
185
|
+
const dig = elementDigest(item);
|
|
186
|
+
if (dig === null) {
|
|
187
|
+
walk(item, [...path, `[${i}]`]);
|
|
188
|
+
kept.push(item);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
note(dig);
|
|
192
|
+
const found = byDigest.get(dig);
|
|
193
|
+
// Section 7.1 step d: an element whose digest has no disclosure is removed, not left behind
|
|
194
|
+
// as a placeholder for the caller to trip over.
|
|
195
|
+
if (!found) {
|
|
196
|
+
withheld++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (found.kind !== 'element') {
|
|
200
|
+
throw new QredentialError('malformed_credential', 'an array element digest resolved to an object property disclosure');
|
|
201
|
+
}
|
|
202
|
+
used.add(dig);
|
|
203
|
+
const at = `${path.join('.')}[${kept.length}]`;
|
|
204
|
+
disclosed.push(at);
|
|
205
|
+
walk(found.value, [...path, `[${kept.length}]`]);
|
|
206
|
+
kept.push(found.value);
|
|
207
|
+
}
|
|
208
|
+
node.length = 0;
|
|
209
|
+
node.push(...kept);
|
|
210
|
+
};
|
|
211
|
+
const walk = (node, path) => {
|
|
212
|
+
if (Array.isArray(node))
|
|
213
|
+
return resolveArray(node, path);
|
|
214
|
+
if (node !== null && typeof node === 'object') {
|
|
215
|
+
return resolveObject(node, path);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const claims = {};
|
|
219
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
220
|
+
if (REGISTERED_CLAIMS.has(key))
|
|
221
|
+
continue;
|
|
222
|
+
claims[key] = structuredClone(value);
|
|
223
|
+
}
|
|
224
|
+
// The top level `_sd` lives on the payload, so it is copied in for the walk and stripped after.
|
|
225
|
+
const root = { ...claims };
|
|
226
|
+
if (payload['_sd'] !== undefined)
|
|
227
|
+
root['_sd'] = structuredClone(payload['_sd']);
|
|
228
|
+
resolveObject(root, []);
|
|
229
|
+
// Section 7.1 step 5: anything the payload never referenced is an attempt to add a claim after
|
|
230
|
+
// the fact, and fails the whole credential rather than being skipped.
|
|
231
|
+
const orphan = [...byDigest.entries()].find(([dig]) => !used.has(dig));
|
|
232
|
+
if (orphan) {
|
|
233
|
+
const [, d] = orphan;
|
|
234
|
+
const what = d.name !== undefined ? `"${d.name}"` : 'an array element';
|
|
235
|
+
throw new QredentialError('malformed_credential', `disclosure for ${what} does not match any digest signed by the issuer`);
|
|
236
|
+
}
|
|
237
|
+
return { claims: root, disclosed, withheld };
|
|
238
|
+
}
|
|
239
|
+
/** An array element stands for a hidden value when it is exactly `{"...": "<digest>"}`. */
|
|
240
|
+
function elementDigest(item) {
|
|
241
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
242
|
+
return null;
|
|
243
|
+
const keys = Object.keys(item);
|
|
244
|
+
if (keys.length !== 1 || keys[0] !== '...')
|
|
245
|
+
return null;
|
|
246
|
+
const value = item['...'];
|
|
247
|
+
return typeof value === 'string' ? value : null;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Work out where each disclosure sits in the credential.
|
|
251
|
+
*
|
|
252
|
+
* A nested disclosure's path does not exist until its parent is resolved: the digest for
|
|
253
|
+
* `address.locality` lives inside the value of the `address` disclosure, so the walk has to resolve
|
|
254
|
+
* as it goes, exactly the way a verifier does. That is also why each location carries its ancestors.
|
|
255
|
+
* RFC 9901 section 4.2.6 is explicit that sending a nested disclosure without the one containing it
|
|
256
|
+
* is illegal, so a holder who asks for `address.locality` has to send `address` too, and this is
|
|
257
|
+
* what lets present() work that out rather than making the caller do it.
|
|
258
|
+
*
|
|
259
|
+
* Array indices are positions in the credential as issued. They stay stable whatever the holder
|
|
260
|
+
* decides to withhold, which is the only way a selector written against the credential can keep
|
|
261
|
+
* meaning what it said.
|
|
262
|
+
*/
|
|
263
|
+
export async function disclosureLocations(payload, disclosures) {
|
|
264
|
+
const byDigest = new Map();
|
|
265
|
+
for (const raw of disclosures) {
|
|
266
|
+
byDigest.set(await digest(raw), { raw, parsed: parseDisclosure(raw) });
|
|
267
|
+
}
|
|
268
|
+
const found = new Map();
|
|
269
|
+
const walk = (node, path, ancestors) => {
|
|
270
|
+
if (Array.isArray(node)) {
|
|
271
|
+
for (let i = 0; i < node.length; i++) {
|
|
272
|
+
const at = `${path}[${i}]`;
|
|
273
|
+
const dig = elementDigest(node[i]);
|
|
274
|
+
if (dig === null) {
|
|
275
|
+
walk(node[i], at, ancestors);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const hit = byDigest.get(dig);
|
|
279
|
+
if (!hit)
|
|
280
|
+
continue;
|
|
281
|
+
found.set(hit.raw, { path: at, requires: [...ancestors] });
|
|
282
|
+
walk(hit.parsed.value, at, [...ancestors, hit.raw]);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (node === null || typeof node !== 'object')
|
|
287
|
+
return;
|
|
288
|
+
const object = node;
|
|
289
|
+
const sd = object['_sd'];
|
|
290
|
+
if (Array.isArray(sd)) {
|
|
291
|
+
for (const dig of sd) {
|
|
292
|
+
if (typeof dig !== 'string')
|
|
293
|
+
continue;
|
|
294
|
+
const hit = byDigest.get(dig);
|
|
295
|
+
if (!hit || hit.parsed.name === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
const at = path ? `${path}.${hit.parsed.name}` : hit.parsed.name;
|
|
298
|
+
found.set(hit.raw, { path: at, requires: [...ancestors] });
|
|
299
|
+
walk(hit.parsed.value, at, [...ancestors, hit.raw]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const [key, value] of Object.entries(object)) {
|
|
303
|
+
if (key === '_sd' || key === '_sd_alg')
|
|
304
|
+
continue;
|
|
305
|
+
walk(value, path ? `${path}.${key}` : key, ancestors);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
walk(payload, '', []);
|
|
309
|
+
return found;
|
|
310
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Alg, Jwk } from './types.js';
|
|
2
|
+
export type StatusValue = 'valid' | 'invalid' | 'suspended' | 'unknown';
|
|
3
|
+
export interface StatusListToken {
|
|
4
|
+
issuer: string;
|
|
5
|
+
uri?: string;
|
|
6
|
+
issuedAt?: number;
|
|
7
|
+
expiresAt?: number;
|
|
8
|
+
bits: number;
|
|
9
|
+
bytes: Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse a Token Status List JWT that the verifier already has on disk.
|
|
13
|
+
*
|
|
14
|
+
* Signature checking happens in verify(), against the same trust list as the credential, because a
|
|
15
|
+
* status list from an unverified source is worse than no status list: it lets an attacker clear a
|
|
16
|
+
* revoked credential.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseStatusList(token: string): Promise<{
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
list: StatusListToken;
|
|
21
|
+
}>;
|
|
22
|
+
/** Read one entry. Values are packed least significant bits first, per the spec. */
|
|
23
|
+
export declare function readStatus(list: StatusListToken, idx: number): StatusValue;
|
|
24
|
+
export declare function isStale(list: StatusListToken, maxAge: number | string | undefined, now?: number): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Publish a status list.
|
|
27
|
+
*
|
|
28
|
+
* The verifier side of revocation is useless without this, and leaving issuers to hand roll the
|
|
29
|
+
* bitstring is how you end up with lists that disagree about bit order.
|
|
30
|
+
*
|
|
31
|
+
* Size it generously and set it once: a list covering a million credentials is 125 KB before
|
|
32
|
+
* compression and a few KB after, because the bits are nearly all zero.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createStatusList(options: {
|
|
35
|
+
issuer: string;
|
|
36
|
+
key: Jwk;
|
|
37
|
+
kid: string;
|
|
38
|
+
alg?: Alg;
|
|
39
|
+
/** Where verifiers refresh this list. Becomes the `sub` claim. */
|
|
40
|
+
uri: string;
|
|
41
|
+
/** How many credentials the list covers. */
|
|
42
|
+
size: number;
|
|
43
|
+
revoked?: number[];
|
|
44
|
+
suspended?: number[];
|
|
45
|
+
/** Seconds, or a duration string. Verifiers compare this against maxStatusAge. */
|
|
46
|
+
expiresIn?: number | string;
|
|
47
|
+
/** Override the issue time. Exists for tests and for republishing a historical list. */
|
|
48
|
+
issuedAt?: number;
|
|
49
|
+
}): Promise<string>;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { b64url, b64urlJson, unb64url, unb64urlJson } from './bytes.js';
|
|
2
|
+
import { deflate, inflateEither } from './compress.js';
|
|
3
|
+
import { importPrivateKey, sign } from './crypto.js';
|
|
4
|
+
import { seconds, nowSeconds } from './duration.js';
|
|
5
|
+
import { QredentialError } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Parse a Token Status List JWT that the verifier already has on disk.
|
|
8
|
+
*
|
|
9
|
+
* Signature checking happens in verify(), against the same trust list as the credential, because a
|
|
10
|
+
* status list from an unverified source is worse than no status list: it lets an attacker clear a
|
|
11
|
+
* revoked credential.
|
|
12
|
+
*/
|
|
13
|
+
export async function parseStatusList(token) {
|
|
14
|
+
const parts = token.split('.');
|
|
15
|
+
if (parts.length !== 3) {
|
|
16
|
+
throw new QredentialError('malformed_status_list', 'status list token is not a JWT');
|
|
17
|
+
}
|
|
18
|
+
let payload;
|
|
19
|
+
try {
|
|
20
|
+
payload = unb64urlJson(parts[1]);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new QredentialError('malformed_status_list', 'status list payload is not readable', {
|
|
24
|
+
cause: error,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const sl = payload['status_list'];
|
|
28
|
+
if (!sl || typeof sl.lst !== 'string') {
|
|
29
|
+
throw new QredentialError('malformed_status_list', 'status list token has no status_list.lst');
|
|
30
|
+
}
|
|
31
|
+
const bits = sl.bits ?? 1;
|
|
32
|
+
if (![1, 2, 4, 8].includes(bits)) {
|
|
33
|
+
throw new QredentialError('malformed_status_list', `unsupported status list bit width: ${bits}`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
payload,
|
|
37
|
+
list: {
|
|
38
|
+
issuer: String(payload['iss'] ?? ''),
|
|
39
|
+
uri: typeof payload['sub'] === 'string' ? payload['sub'] : undefined,
|
|
40
|
+
issuedAt: typeof payload['iat'] === 'number' ? payload['iat'] : undefined,
|
|
41
|
+
expiresAt: typeof payload['exp'] === 'number' ? payload['exp'] : undefined,
|
|
42
|
+
bits,
|
|
43
|
+
bytes: await inflateEither(unb64url(sl.lst)),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Read one entry. Values are packed least significant bits first, per the spec. */
|
|
48
|
+
export function readStatus(list, idx) {
|
|
49
|
+
if (idx < 0 || !Number.isInteger(idx)) {
|
|
50
|
+
throw new QredentialError('invalid_option', `invalid status index: ${idx}`);
|
|
51
|
+
}
|
|
52
|
+
const perByte = 8 / list.bits;
|
|
53
|
+
const byteIndex = Math.floor(idx / perByte);
|
|
54
|
+
const byte = list.bytes[byteIndex];
|
|
55
|
+
if (byte === undefined)
|
|
56
|
+
return 'unknown';
|
|
57
|
+
const shift = (idx % perByte) * list.bits;
|
|
58
|
+
const mask = (1 << list.bits) - 1;
|
|
59
|
+
const value = (byte >> shift) & mask;
|
|
60
|
+
if (value === 0)
|
|
61
|
+
return 'valid';
|
|
62
|
+
if (value === 1)
|
|
63
|
+
return 'invalid';
|
|
64
|
+
if (value === 2)
|
|
65
|
+
return 'suspended';
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
export function isStale(list, maxAge, now = nowSeconds()) {
|
|
69
|
+
if (list.expiresAt !== undefined && now > list.expiresAt)
|
|
70
|
+
return true;
|
|
71
|
+
if (maxAge === undefined)
|
|
72
|
+
return false;
|
|
73
|
+
if (list.issuedAt === undefined)
|
|
74
|
+
return true;
|
|
75
|
+
return now - list.issuedAt > seconds(maxAge);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Publish a status list.
|
|
79
|
+
*
|
|
80
|
+
* The verifier side of revocation is useless without this, and leaving issuers to hand roll the
|
|
81
|
+
* bitstring is how you end up with lists that disagree about bit order.
|
|
82
|
+
*
|
|
83
|
+
* Size it generously and set it once: a list covering a million credentials is 125 KB before
|
|
84
|
+
* compression and a few KB after, because the bits are nearly all zero.
|
|
85
|
+
*/
|
|
86
|
+
export async function createStatusList(options) {
|
|
87
|
+
const alg = options.alg ?? 'ES256';
|
|
88
|
+
const iat = options.issuedAt ?? nowSeconds();
|
|
89
|
+
if (!Number.isSafeInteger(options.size) || options.size < 1) {
|
|
90
|
+
throw new QredentialError('invalid_option', `status list size must be a positive integer, received ${options.size}`);
|
|
91
|
+
}
|
|
92
|
+
let bytes;
|
|
93
|
+
try {
|
|
94
|
+
bytes = new Uint8Array(Math.ceil(options.size / 8));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// A size large enough to fail allocation is a caller mistake, not a crash to pass upward.
|
|
98
|
+
throw new QredentialError('invalid_option', `status list of ${options.size} entries cannot be allocated`, { cause: error });
|
|
99
|
+
}
|
|
100
|
+
const set = (indices, value) => {
|
|
101
|
+
for (const idx of indices ?? []) {
|
|
102
|
+
if (idx < 0 || idx >= options.size || !Number.isInteger(idx)) {
|
|
103
|
+
throw new QredentialError('invalid_option', `status index ${idx} is outside a list of ${options.size}`);
|
|
104
|
+
}
|
|
105
|
+
bytes[Math.floor(idx / 8)] |= value << idx % 8;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
set(options.revoked, 1);
|
|
109
|
+
set(options.suspended, 2);
|
|
110
|
+
const payload = {
|
|
111
|
+
iss: options.issuer,
|
|
112
|
+
sub: options.uri,
|
|
113
|
+
iat,
|
|
114
|
+
status_list: { bits: 1, lst: b64url(await deflate(bytes, 'deflate')) },
|
|
115
|
+
};
|
|
116
|
+
if (options.expiresIn !== undefined)
|
|
117
|
+
payload['exp'] = iat + seconds(options.expiresIn);
|
|
118
|
+
const header = { alg, typ: 'statuslist+jwt', kid: options.kid };
|
|
119
|
+
const input = `${b64urlJson(header)}.${b64urlJson(payload)}`;
|
|
120
|
+
const key = await importPrivateKey(options.key, alg);
|
|
121
|
+
return `${input}.${b64url(await sign(input, key, alg))}`;
|
|
122
|
+
}
|