qredential 0.2.0 → 0.3.1

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 CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  Verify a digital credential from a QR code with **no network connection**.
10
10
 
11
+ Someone shows you a code. You need to know one thing about them, and you need to know it is true.
12
+ This library reads the code, checks the signature, and answers, without asking anybody.
13
+
11
14
  ```ts
12
15
  import { verify } from 'qredential'
13
16
 
@@ -19,6 +22,38 @@ No server call. No lookup. No account. The proof travels inside the QR code itse
19
22
 
20
23
  ---
21
24
 
25
+ ## What this actually does
26
+
27
+ If you have not worked with digital credentials before, this section is for you. Nothing here
28
+ assumes you have.
29
+
30
+ A credential is a statement somebody signed. "This person is over 18." "This person may drive a
31
+ car." The signature is what makes it worth anything: it says an authority stands behind the
32
+ statement, and that nobody edited it afterwards.
33
+
34
+ On paper, and in a PDF, one signature covers the whole document. To prove a single line of it you
35
+ hand over all of it. Show a driving licence to prove your age and the other person also learns your
36
+ address, your licence number and your exact date of birth. They never asked for any of that. They
37
+ have it now anyway.
38
+
39
+ Selective disclosure breaks that trade. The issuer signs each fact separately, and the credential
40
+ carries only a fingerprint of each one. You choose which facts to reveal, and the rest stay
41
+ fingerprints that say nothing about their contents. The signature still checks out, because it was
42
+ never a signature over one indivisible blob.
43
+
44
+ All of it fits inside the code the verifier scans: the signature, the fingerprints, and the facts
45
+ you chose to share. Nothing is fetched while verifying, so a door, a bus, a rural clinic or an
46
+ aeroplane at cruising altitude can check a credential with no network at all.
47
+
48
+ There is one last piece, and it is the one people forget. Anything you can scan, you can photograph.
49
+ So the person presenting also has to sign a fresh challenge with a private key that never leaves
50
+ their device. Without that step, a screenshot of somebody else's credential would pass. This library
51
+ refuses any presentation that lacks it, and the section on [proving the
52
+ holder](#proving-the-holder-not-just-the-credential) explains how.
53
+
54
+ The format is SD-JWT, standardised as [RFC 9901](https://www.rfc-editor.org/rfc/rfc9901.html) in
55
+ November 2025. It is what the European digital identity wallets and the OpenID specifications use.
56
+
22
57
  ## Why this exists
23
58
 
24
59
  I built the eCNH, Brazil's digital driver's licence, used by more than 40 million people. The part
@@ -92,9 +127,16 @@ ones to reveal, and the signature still checks out on what is left:
92
127
  ```ts
93
128
  // The credential contains name, address, birth date, document number.
94
129
  // The bar only gets to see one thing.
95
- const presentation = await present(credential, { disclose: ['over_18'] })
130
+ const presentation = await present(credential, {
131
+ disclose: ['over_18'],
132
+ keyBinding: { key: holderPrivateJwk, audience: 'https://bar.example/door', nonce: challenge },
133
+ })
96
134
 
97
- const result = await verify(presentation, { trust })
135
+ const result = await verify(presentation, {
136
+ trust,
137
+ nonce: challenge,
138
+ audience: 'https://bar.example/door',
139
+ })
98
140
  result.claims // { over_18: true }
99
141
  result.claims.address // undefined, and it was never transmitted
100
142
  ```
@@ -172,6 +214,18 @@ If the cached list is too old, you get `result.ok === false` with
172
214
  `result.reason === 'status_list_stale'` rather than a false yes. Deciding what to do when you cannot
173
215
  be sure is your call, and the library refuses to make it quietly for you.
174
216
 
217
+ ## Examples
218
+
219
+ Runnable scenarios, each a single file with no setup beyond `npm run build`:
220
+ [a door checking somebody is over 18](examples/age-check-at-the-door.mjs), [issuing a
221
+ credential](examples/issue-a-credential.mjs), [revoking one that is already in somebody's
222
+ pocket](examples/revoke-a-credential.mjs), and [what a credential costs in QR
223
+ characters](examples/sizes.mjs). CI runs all of them, so they cannot quietly stop working.
224
+
225
+ [examples/README.md](examples/README.md) has the index and two findings the prose does not make:
226
+ a presentation with key binding is larger than the credential it came from, and a revocation list
227
+ covering a million credentials is about 600 characters.
228
+
175
229
  ## API
176
230
 
177
231
  Four functions. That is the whole surface.
package/dist/crypto.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Alg, Jwk } from './types.js';
1
+ import type { Alg, Jwk, SdAlg } from './types.js';
2
2
  export declare function importPrivateKey(jwk: Jwk, alg: Alg): Promise<CryptoKey>;
3
3
  export declare function importPublicKey(jwk: Jwk, alg: Alg): Promise<CryptoKey>;
4
4
  export declare function sign(data: string, key: CryptoKey, alg: Alg): Promise<Uint8Array>;
@@ -10,4 +10,8 @@ export declare function verifySignature(data: string, signature: string, key: Cr
10
10
  * only be used one way here and guessing wrong fails loudly at import rather than quietly.
11
11
  */
12
12
  export declare function algForJwk(jwk: Jwk): Alg;
13
+ export declare function isSupportedSdAlg(alg: string): alg is SdAlg;
14
+ /** Map an IANA Named Information Hash Algorithm name to its WebCrypto digest algorithm name. */
15
+ export declare function webCryptoDigestAlg(sdAlg: string): AlgorithmIdentifier;
16
+ export declare function digestHash(data: Uint8Array, sdAlg?: string): Promise<Uint8Array>;
13
17
  export declare function sha256(data: Uint8Array): Promise<Uint8Array>;
package/dist/crypto.js CHANGED
@@ -66,7 +66,26 @@ export function algForJwk(jwk) {
66
66
  return 'EdDSA';
67
67
  throw new QredentialError('unsupported_alg', `cannot tell which algorithm this key is for: kty ${String(jwk.kty)}, crv ${String(jwk.crv)}`);
68
68
  }
69
- export async function sha256(data) {
70
- const buf = await crypto.subtle.digest('SHA-256', data);
69
+ const SD_ALG_MAP = {
70
+ 'sha-256': 'SHA-256',
71
+ 'sha-384': 'SHA-384',
72
+ 'sha-512': 'SHA-512',
73
+ };
74
+ export function isSupportedSdAlg(alg) {
75
+ return alg === 'sha-256' || alg === 'sha-384' || alg === 'sha-512';
76
+ }
77
+ /** Map an IANA Named Information Hash Algorithm name to its WebCrypto digest algorithm name. */
78
+ export function webCryptoDigestAlg(sdAlg) {
79
+ if (isSupportedSdAlg(sdAlg)) {
80
+ return SD_ALG_MAP[sdAlg];
81
+ }
82
+ throw new QredentialError('unsupported_alg', `unsupported _sd_alg: ${sdAlg}`);
83
+ }
84
+ export async function digestHash(data, sdAlg = 'sha-256') {
85
+ const algName = webCryptoDigestAlg(sdAlg);
86
+ const buf = await crypto.subtle.digest(algName, data);
71
87
  return new Uint8Array(buf);
72
88
  }
89
+ export async function sha256(data) {
90
+ return digestHash(data, 'sha-256');
91
+ }
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@ import { seconds, nowSeconds } from './duration.js';
4
4
  import { pack, unpack, isEnvelope } from './envelope.js';
5
5
  import { parseStatusList, readStatus, isStale } from './status.js';
6
6
  import { REGISTERED_CLAIMS, digest, sdHash, makeDisclosure, splitCombined, joinCombined, disclosureLocations, reconstructClaims, } from './sdjwt.js';
7
- import { QredentialError } from './errors.js';
7
+ import { isSupportedSdAlg } from './crypto.js';
8
+ import { isQredentialError, QredentialError } from './errors.js';
8
9
  export { QredentialError, isQredentialError } from './errors.js';
9
10
  /**
10
11
  * Turn a rejected result into a thrown {@link QredentialError}, for callers who would rather use
@@ -75,8 +76,12 @@ export async function issue(options) {
75
76
  if (reserved.length > 0) {
76
77
  throw new QredentialError('invalid_option', `these claim names describe the token, not the subject, and cannot be made disclosable: ${reserved.join(', ')}`);
77
78
  }
79
+ const sdAlg = options.sdAlg ?? 'sha-256';
80
+ if (!isSupportedSdAlg(sdAlg)) {
81
+ throw new QredentialError('unsupported_alg', `unsupported _sd_alg: ${sdAlg}`);
82
+ }
78
83
  const disclosures = disclosable.map((name) => makeDisclosure(name, options.claims[name]));
79
- const digests = shuffle(await Promise.all(disclosures.map((d) => digest(d.raw))));
84
+ const digests = shuffle(await Promise.all(disclosures.map((d) => digest(d.raw, sdAlg))));
80
85
  const payload = { iss: options.issuer, iat };
81
86
  for (const [key, value] of Object.entries(options.claims)) {
82
87
  if (!disclosable.includes(key))
@@ -105,7 +110,7 @@ export async function issue(options) {
105
110
  }
106
111
  if (digests.length > 0) {
107
112
  payload['_sd'] = digests;
108
- payload['_sd_alg'] = 'sha-256';
113
+ payload['_sd_alg'] = sdAlg;
109
114
  }
110
115
  const header = { alg, typ: 'dc+sd-jwt', kid: options.kid };
111
116
  const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`;
@@ -162,13 +167,14 @@ export async function present(credential, options) {
162
167
  if (bound === null) {
163
168
  throw new QredentialError('invalid_option', 'this credential has no cnf claim, so the issuer never bound a holder key and a proof would mean nothing');
164
169
  }
170
+ const sdAlg = readSdAlg(jwt);
165
171
  const alg = options.keyBinding.alg ?? algForJwk(options.keyBinding.key);
166
172
  const kbPayload = {
167
173
  iat: nowSeconds(),
168
174
  aud: options.keyBinding.audience,
169
175
  nonce: options.keyBinding.nonce,
170
176
  // Commits to exactly this set of disclosures, so a relay cannot add or strip one afterwards.
171
- sd_hash: await sdHash(jwt, kept),
177
+ sd_hash: await sdHash(jwt, kept, sdAlg),
172
178
  };
173
179
  const kbInput = `${b64urlJson({ alg, typ: 'kb+jwt' })}.${b64urlJson(kbPayload)}`;
174
180
  const holderKey = await importPrivateKey(options.keyBinding.key, alg);
@@ -189,6 +195,19 @@ function readHolderKey(jwt) {
189
195
  return null;
190
196
  }
191
197
  }
198
+ /** Pull _sd_alg out of a credential's payload, defaulting to sha-256 per RFC 9901. */
199
+ function readSdAlg(jwt) {
200
+ const segments = jwt.split('.');
201
+ if (segments.length !== 3)
202
+ return 'sha-256';
203
+ try {
204
+ const payload = unb64urlJson(segments[1]);
205
+ return payload['_sd_alg'] ?? 'sha-256';
206
+ }
207
+ catch {
208
+ return 'sha-256';
209
+ }
210
+ }
192
211
  export async function verify(input, options) {
193
212
  const now = options.now ?? nowSeconds();
194
213
  const skew = options.clockSkew ?? 60;
@@ -273,6 +292,9 @@ export async function verify(input, options) {
273
292
  withheld = rebuilt.withheld;
274
293
  }
275
294
  catch (error) {
295
+ if (isQredentialError(error) && error.code === 'unsupported_alg') {
296
+ return reject('unsupported_alg', error.message);
297
+ }
276
298
  return reject('digest_mismatch', error.message);
277
299
  }
278
300
  let revocationChecked = false;
@@ -436,7 +458,20 @@ async function checkHolderProof(input) {
436
458
  holderVerified: false,
437
459
  };
438
460
  }
439
- const expected = await sdHash(jwt, disclosures);
461
+ const sdAlg = payload['_sd_alg'] ?? 'sha-256';
462
+ let expected;
463
+ try {
464
+ expected = await sdHash(jwt, disclosures, sdAlg);
465
+ }
466
+ catch (error) {
467
+ if (isQredentialError(error) && error.code === 'unsupported_alg') {
468
+ return {
469
+ rejected: reject('unsupported_alg', error.message),
470
+ holderVerified: false,
471
+ };
472
+ }
473
+ throw error;
474
+ }
440
475
  if (kbPayload['sd_hash'] !== expected) {
441
476
  return {
442
477
  rejected: reject('holder_proof_invalid', 'the holder proof commits to a different set of disclosures than the one presented'),
package/dist/sdjwt.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SdAlg } from './types.js';
1
2
  export declare const SEPARATOR = "~";
2
3
  /**
3
4
  * Registered claims that describe the token rather than the subject.
@@ -27,9 +28,9 @@ export declare function parseDisclosure(raw: string): Disclosure;
27
28
  * signature covers exactly this set of disclosures. Without it a relay could strip or add
28
29
  * disclosures after the holder signed, and the proof would still check out.
29
30
  */
30
- export declare function sdHash(jwt: string, disclosures: string[]): Promise<string>;
31
+ export declare function sdHash(jwt: string, disclosures: string[], alg?: string): Promise<string>;
31
32
  /** 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 digest(raw: string, alg?: string): Promise<string>;
33
34
  export declare function splitCombined(combined: string): {
34
35
  jwt: string;
35
36
  disclosures: string[];
@@ -44,6 +45,16 @@ export declare function joinCombined(jwt: string, disclosures: string[], keyBind
44
45
  * skipped: a verifier that silently ignores an injected claim is the bug that makes the format
45
46
  * pointless.
46
47
  */
48
+ /**
49
+ * The hash a credential says its digests were made with, validated.
50
+ *
51
+ * RFC 9901 section 4.1.1 requires sha-256 and allows any name from the IANA Named Information Hash
52
+ * Algorithm Registry. These three are the ones WebCrypto computes in every runtime this targets;
53
+ * anything else is refused by name rather than guessed at. Reading it in one place is what keeps
54
+ * the digest map, the claim reconstruction and the path resolver from disagreeing about which hash
55
+ * a credential uses, which is the kind of disagreement that makes a claim silently unreachable.
56
+ */
57
+ export declare function resolveSdAlg(payload: Record<string, unknown>): SdAlg;
47
58
  /**
48
59
  * Rebuild the claim set, following the processing model in RFC 9901 section 7.1.
49
60
  *
package/dist/sdjwt.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { b64url, b64urlJson, unb64urlJson, randomBytes, utf8 } from './bytes.js';
2
- import { sha256 } from './crypto.js';
2
+ import { digestHash, isSupportedSdAlg } from './crypto.js';
3
3
  import { QredentialError } from './errors.js';
4
4
  export const SEPARATOR = '~';
5
5
  /**
@@ -55,12 +55,12 @@ export function parseDisclosure(raw) {
55
55
  * signature covers exactly this set of disclosures. Without it a relay could strip or add
56
56
  * disclosures after the holder signed, and the proof would still check out.
57
57
  */
58
- export async function sdHash(jwt, disclosures) {
59
- return b64url(await sha256(utf8(joinCombined(jwt, disclosures))));
58
+ export async function sdHash(jwt, disclosures, alg = 'sha-256') {
59
+ return b64url(await digestHash(utf8(joinCombined(jwt, disclosures)), alg));
60
60
  }
61
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)));
62
+ export async function digest(raw, alg = 'sha-256') {
63
+ return b64url(await digestHash(utf8(raw), alg));
64
64
  }
65
65
  export function splitCombined(combined) {
66
66
  const parts = combined.split(SEPARATOR);
@@ -102,6 +102,22 @@ export function joinCombined(jwt, disclosures, keyBinding) {
102
102
  * skipped: a verifier that silently ignores an injected claim is the bug that makes the format
103
103
  * pointless.
104
104
  */
105
+ /**
106
+ * The hash a credential says its digests were made with, validated.
107
+ *
108
+ * RFC 9901 section 4.1.1 requires sha-256 and allows any name from the IANA Named Information Hash
109
+ * Algorithm Registry. These three are the ones WebCrypto computes in every runtime this targets;
110
+ * anything else is refused by name rather than guessed at. Reading it in one place is what keeps
111
+ * the digest map, the claim reconstruction and the path resolver from disagreeing about which hash
112
+ * a credential uses, which is the kind of disagreement that makes a claim silently unreachable.
113
+ */
114
+ export function resolveSdAlg(payload) {
115
+ const alg = payload['_sd_alg'] ?? 'sha-256';
116
+ if (!isSupportedSdAlg(alg)) {
117
+ throw new QredentialError('unsupported_alg', `unsupported _sd_alg: ${alg}`);
118
+ }
119
+ return alg;
120
+ }
105
121
  /**
106
122
  * Rebuild the claim set, following the processing model in RFC 9901 section 7.1.
107
123
  *
@@ -116,12 +132,10 @@ export function joinCombined(jwt, disclosures, keyBinding) {
116
132
  * verifier that silently ignores any of those is the bug that makes the format pointless.
117
133
  */
118
134
  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}`);
135
+ const sdAlg = resolveSdAlg(payload);
122
136
  const byDigest = new Map();
123
137
  for (const raw of disclosures) {
124
- const dig = await digest(raw);
138
+ const dig = await digest(raw, sdAlg);
125
139
  // Keying by digest would quietly swallow a repeat, and a presentation that sends the same
126
140
  // disclosure twice is malformed however harmless it looks.
127
141
  if (byDigest.has(dig)) {
@@ -261,9 +275,10 @@ function elementDigest(item) {
261
275
  * meaning what it said.
262
276
  */
263
277
  export async function disclosureLocations(payload, disclosures) {
278
+ const sdAlg = resolveSdAlg(payload);
264
279
  const byDigest = new Map();
265
280
  for (const raw of disclosures) {
266
- byDigest.set(await digest(raw), { raw, parsed: parseDisclosure(raw) });
281
+ byDigest.set(await digest(raw, sdAlg), { raw, parsed: parseDisclosure(raw) });
267
282
  }
268
283
  const found = new Map();
269
284
  const walk = (node, path, ancestors) => {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export type Alg = 'ES256' | 'EdDSA';
2
+ /**
3
+ * Hashing algorithms supported for SD-JWT selective disclosure (RFC 9901 section 4.1.1).
4
+ * From the IANA Named Information Hash Algorithm Registry.
5
+ */
6
+ export type SdAlg = 'sha-256' | 'sha-384' | 'sha-512';
2
7
  export interface Jwk {
3
8
  kty: string;
4
9
  crv?: string;
@@ -38,6 +43,11 @@ export interface IssueOptions {
38
43
  key: Jwk;
39
44
  kid: string;
40
45
  alg?: Alg;
46
+ /**
47
+ * Hashing algorithm for selective disclosure and key binding sd_hash.
48
+ * Defaults to 'sha-256' (RFC 9901).
49
+ */
50
+ sdAlg?: SdAlg;
41
51
  claims: Record<string, unknown>;
42
52
  /** Claim names the holder may withhold at presentation time. Everything else is always visible. */
43
53
  disclose?: string[];
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "qredential",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Verify a digital credential from a QR code with no network connection. SD-JWT selective disclosure, offline revocation, zero dependencies.",
5
5
  "homepage": "https://qredential.js.org/",
6
6
  "bugs": {
7
7
  "url": "https://github.com/george-veras/qredential/issues"
8
8
  },
9
9
  "type": "module",
10
+ "sideEffects": false,
10
11
  "main": "./dist/index.js",
11
12
  "types": "./dist/index.d.ts",
12
13
  "exports": {
@@ -20,11 +21,16 @@
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
23
27
  "scripts": {
24
28
  "build": "tsc -p tsconfig.build.json",
25
29
  "test": "vitest run",
30
+ "coverage": "vitest run --coverage",
26
31
  "test:watch": "vitest",
27
32
  "typecheck": "tsc --noEmit",
33
+ "lint": "oxlint src test scripts",
28
34
  "build:site": "node scripts/build-site.mjs",
29
35
  "check:site": "node scripts/build-site.mjs && git diff --exit-code docs/",
30
36
  "prepack": "npm run build",
@@ -34,6 +40,7 @@
34
40
  "build:og": "node scripts/build-og.mjs",
35
41
  "check:playground": "node scripts/check-playground.mjs",
36
42
  "check:seo": "node scripts/check-seo.mjs",
43
+ "check:docs": "node scripts/check-docs.mjs",
37
44
  "check:a11y": "node scripts/check-a11y.mjs"
38
45
  },
39
46
  "keywords": [
@@ -64,18 +71,20 @@
64
71
  "node": ">=20"
65
72
  },
66
73
  "devDependencies": {
67
- "@sd-jwt/core": "^0.19.0",
74
+ "@sd-jwt/core": "^0.21.0",
68
75
  "@sd-jwt/crypto-nodejs": "^0.19.0",
69
- "@types/node": "^22.10.0",
76
+ "@types/node": "^26.6.2",
70
77
  "@vitest/browser": "^5.0.1",
71
78
  "@vitest/browser-playwright": "^5.0.1",
79
+ "@vitest/coverage-v8": "^5.0.1",
72
80
  "axe-core": "^4.13.0",
73
81
  "esbuild": "^0.28.2",
74
- "fast-check": "^3.23.2",
75
- "marked": "^15.0.12",
82
+ "fast-check": "^4.10.1",
83
+ "marked": "^18.0.13",
84
+ "oxlint": "^1.85.0",
76
85
  "playwright": "^1.63.0",
77
- "qrcode-generator": "^1.5.2",
78
- "typescript": "^5.7.0",
86
+ "qrcode-generator": "^2.0.4",
87
+ "typescript": "^7.0.2",
79
88
  "vitest": "^5.0.1"
80
89
  }
81
90
  }