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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 George Veras Valentim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # qredential
2
+
3
+ [![ci](https://github.com/george-veras/qredential/actions/workflows/ci.yml/badge.svg)](https://github.com/george-veras/qredential/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/qredential)](https://www.npmjs.com/package/qredential)
5
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/george-veras/qredential/badge)](https://scorecard.dev/viewer/?uri=github.com/george-veras/qredential)
6
+
7
+ [English](https://qredential.js.org/) · [Português](https://qredential.js.org/pt/) · [Español](https://qredential.js.org/es/) · [Français](https://qredential.js.org/fr/) · [Deutsch](https://qredential.js.org/de/) · [日本語](https://qredential.js.org/ja/) · [한국어](https://qredential.js.org/ko/) · [简体中文](https://qredential.js.org/zh-Hans/) · [繁體中文](https://qredential.js.org/zh-Hant/)
8
+
9
+ Verify a digital credential from a QR code with **no network connection**.
10
+
11
+ ```ts
12
+ import { verify } from 'qredential'
13
+
14
+ const result = await verify(scannedText, { trust })
15
+ // result.ok === true, result.claims.given_name === 'Ana'
16
+ ```
17
+
18
+ No server call. No lookup. No account. The proof travels inside the QR code itself.
19
+
20
+ ---
21
+
22
+ ## Why this exists
23
+
24
+ I built the eCNH, Brazil's digital driver's licence, used by more than 40 million people. The part
25
+ that taught me the most was not the app. It was the roadside: a police officer scanning a licence on
26
+ a highway with one bar of signal, or none, and needing a yes or no in under a second.
27
+
28
+ Everything you actually need for that answer can fit in the QR code. The signature proves the
29
+ issuer. The claims are right there. The only thing you need from the outside world is the issuer's
30
+ public key, and that changes so rarely that you can ship it and refresh it once a week.
31
+
32
+ The libraries for this exist, but they are enterprise SDKs: heavy, tied to one country's profile,
33
+ and documented as if you already work in the identity industry. I wanted the version that a normal
34
+ product engineer can add on a Tuesday.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ npm i qredential
40
+ ```
41
+
42
+ Runs on Node 20+, browsers, and React Native. It uses WebCrypto and nothing Node specific, because
43
+ the verifier is usually a phone. The suite runs on Node 20, 22 and 24 across Linux, macOS and
44
+ Windows, and in Chromium, Firefox and WebKit, on every commit.
45
+
46
+ **One caveat on React Native:** it has no `CompressionStream`. Issuing, presenting and verifying all
47
+ work without it, the envelope just stays uncompressed. Offline revocation does not, because reading
48
+ a status list means inflating a bitstring, so polyfill `DecompressionStream` if you need it. A
49
+ verifier that cannot inflate a cached list refuses rather than treating it as clean. A test runs the
50
+ whole flow with both globals deleted, so this is checked rather than assumed.
51
+
52
+ ## Proving the holder, not just the credential
53
+
54
+ Selective disclosure proves the issuer signed these claims. On its own it does not prove the person
55
+ presenting them is the subject, and the gap is not theoretical: a photograph of someone else's code
56
+ carries the same signature.
57
+
58
+ Key binding closes it. The issuer binds the holder's public key; at scan time the wallet signs the
59
+ verifier's fresh challenge with the matching private key. A picture cannot do that.
60
+
61
+ ```ts
62
+ const { credential } = await issue({ ..., holderKey: holderPublicJwk })
63
+
64
+ const presentation = await present(credential, {
65
+ disclose: ['over_18'],
66
+ keyBinding: { key: holderPrivateJwk, audience: 'https://bar.example/door', nonce: challenge },
67
+ })
68
+
69
+ const result = await verify(scanned, { trust, nonce: challenge, audience: 'https://bar.example/door' })
70
+ result.holderVerified // true
71
+ ```
72
+
73
+ The proof commits to the nonce (stops replay), the audience (stops reuse at another verifier), the
74
+ bound key, and a hash of the exact disclosure set (stops a relay adding or stripping one). It
75
+ expires after five minutes by default.
76
+
77
+ **A printed card cannot do this**, and that is a real situation rather than a mistake. Pass
78
+ `acceptWithoutHolderProof: true` to accept one, and the result still reports
79
+ `holderVerified: false`, so the fact never disappears. Without the flag, a presentation with no
80
+ proof is refused: the dangerous case is someone building a door scanner, never hearing of key
81
+ binding, and shipping something a screenshot defeats.
82
+
83
+ ## The 2026 problem it solves
84
+
85
+ Age verification laws are arriving faster than the tooling. The usual implementation asks the user
86
+ to upload a photo of their ID to a third party, which is a privacy disaster and a breach waiting to
87
+ happen.
88
+
89
+ Selective disclosure does it properly. The issuer signs every claim once. The holder chooses which
90
+ ones to reveal, and the signature still checks out on what is left:
91
+
92
+ ```ts
93
+ // The credential contains name, address, birth date, document number.
94
+ // The bar only gets to see one thing.
95
+ const presentation = await present(credential, { disclose: ['over_18'] })
96
+
97
+ const result = await verify(presentation, { trust })
98
+ result.claims // { over_18: true }
99
+ result.claims.address // undefined, and it was never transmitted
100
+ ```
101
+
102
+ The verifier cannot learn the birth date even if it wants to. That property is cryptographic, not a
103
+ promise in a privacy policy.
104
+
105
+ ## What is actually inside
106
+
107
+ Boring, published standards, not an invention of mine:
108
+
109
+ - **SD-JWT** ([RFC 9901](https://www.rfc-editor.org/rfc/rfc9901.html)) for selective disclosure and
110
+ key binding, including nested, array element and recursive disclosure, checked against the
111
+ specification's own test vectors and against an independent implementation
112
+ - **SD-JWT VC** for the credential shape
113
+ - **Token Status List** for revocation that works from a cached copy
114
+ - **base45 plus deflate** for the QR envelope, the same envelope trick the EU covid certificate
115
+ used, chosen for scanner compatibility rather than for raw density
116
+
117
+ If you already speak these, `qredential` is a small ergonomic layer over them. If you do not, you
118
+ should not have to learn them to check whether a ticket is real.
119
+
120
+ ## Size, because this is where naive implementations die
121
+
122
+ A QR code holds about 4300 alphanumeric characters at the largest version, but a code that big is
123
+ unreadable on a cracked phone screen in the sun. The real budget is a QR version around 20 or below.
124
+
125
+ `qredential` compresses before encoding and tells you what you spent:
126
+
127
+ ```ts
128
+ const { qr, bytes } = await issue({ ... })
129
+ bytes // 738
130
+ ```
131
+
132
+ Here is what a realistic driving licence actually costs. Eight claims, a five year expiry, a status
133
+ list pointer, measured by `examples/sizes.mjs`:
134
+
135
+ | credential | characters | QR version |
136
+ |---|---|---|
137
+ | everything visible, nothing withheld | ~740 | 18, scans fine |
138
+ | all eight claims made disclosable | ~1590 | 27, too dense |
139
+ | presenting only `over_18` from that credential | ~1115 | 22, still dense |
140
+
141
+ The uncomfortable row is the middle one, and I would rather you learn it here than after printing
142
+ cards. Selective disclosure roughly doubles the credential, because every disclosable claim costs a
143
+ 128 bit salt plus a digest the issuer has to sign, and the digests stay in the payload whether the
144
+ holder reveals the claim or not. That last part is the whole point, since a digest count that
145
+ changed with what you reveal would leak what you withheld, but it does mean the savings at
146
+ presentation time are smaller than people expect: 30% here, not 80%.
147
+
148
+ The practical advice, which is why `fits()` exists: make disclosable only the claims that a verifier
149
+ might genuinely need to see alone. Two or three, not all of them.
150
+
151
+ ```ts
152
+ fits(qr).advice
153
+ // 'Fits QR version 18 at level M, with 78 characters to spare.'
154
+ ```
155
+
156
+ ## Offline revocation
157
+
158
+ Revocation is the part everyone skips, and then a stolen credential works forever.
159
+
160
+ A status list is a compressed bitstring: one bit per credential, hundreds of thousands of
161
+ credentials in a few kilobytes. Fetch it when you have signal, check it when you do not.
162
+
163
+ ```ts
164
+ const result = await verify(scanned, {
165
+ trust,
166
+ status: cachedStatusList,
167
+ maxStatusAge: '7d' // refuse to answer from a list older than this
168
+ })
169
+ ```
170
+
171
+ If the cached list is too old, you get `result.ok === false` with
172
+ `result.reason === 'status_list_stale'` rather than a false yes. Deciding what to do when you cannot
173
+ be sure is your call, and the library refuses to make it quietly for you.
174
+
175
+ ## API
176
+
177
+ Four functions. That is the whole surface.
178
+
179
+ | function | who calls it |
180
+ |---|---|
181
+ | `issue()` | the issuer, once per credential |
182
+ | `present()` | the holder's wallet, at scan time |
183
+ | `verify()` | the verifier |
184
+ | `fits()` | you, while designing the credential |
185
+
186
+ ## What this is not
187
+
188
+ - Not a wallet. It has no UI and no storage.
189
+ - Not a key management system. You bring your own keys and your own trust list distribution.
190
+ - Not ISO 18013-5 mDL yet. That is CBOR and COSE rather than JWT, and it is on the roadmap, but
191
+ claiming half of a compliance standard is worse than not claiming it.
192
+ - Not audited. It is new. Read the code before you put it between a person and a right they hold.
193
+
194
+ ## Website
195
+
196
+ [**qredential.js.org**](https://qredential.js.org/)
197
+
198
+ - [Documentation](https://qredential.js.org/guide/), including the full API and
199
+ every rejection reason
200
+ - [Playground](https://qredential.js.org/playground/), which runs the whole library
201
+ in your browser. The part worth your time is the attack bench: eight things an attacker would
202
+ actually try, each printing the rejection code it expects, so you can check the library against
203
+ its own claims rather than taking my word for it.
204
+
205
+ The site is built from `docs/` by `npm run build:site` and deployed by GitHub Actions on every push
206
+ to main.
207
+
208
+ ## Errors
209
+
210
+ Two contracts, and that is the whole model:
211
+
212
+ 1. **`verify()` never throws.** For any input at all. It returns a discriminated union, and a
213
+ failure carries a typed `reason`.
214
+ 2. **Everything else throws only `QredentialError`**, which carries a stable `code`.
215
+
216
+ Both are held to by property based tests that generate random strings, malformed keys, corrupt
217
+ envelopes and hostile options and assert that nothing else escapes: no `SyntaxError` from a JSON
218
+ parse, no `DOMException` from WebCrypto, no `RangeError` from an allocation.
219
+
220
+ ```ts
221
+ import { verify, assertVerified, isQredentialError } from 'qredential'
222
+
223
+ // Style 1: look at the result.
224
+ const result = await verify(scanned, { trust })
225
+ if (!result.ok) return refuse(result.reason)
226
+
227
+ // Style 2: let it throw, if that suits your codebase better.
228
+ try {
229
+ const credential = assertVerified(await verify(scanned, { trust }))
230
+ } catch (error) {
231
+ if (isQredentialError(error)) refuse(error.reason ?? error.code)
232
+ }
233
+ ```
234
+
235
+ Codes and reasons are covered by semver; message text is not. Branch on the code, print the
236
+ message. The full tables are in the
237
+ [error handling guide](https://qredential.js.org/guide/#errors).
238
+
239
+ ## Security
240
+
241
+ Report vulnerabilities privately through the Security tab. Scope, response times and an honest
242
+ account of what this library has not had are in [SECURITY.md](SECURITY.md).
243
+
244
+ ## Translations
245
+
246
+ The whole site is in nine languages: the landing page, the guide and the playground, including the
247
+ live demo, which answers in the language of the page it is on. Most translations have **not been
248
+ read by a native speaker**, and each guide page says so at the top rather than pretending
249
+ otherwise.
250
+
251
+ | Language | Code | Status |
252
+ |---|---|---|
253
+ | [English](https://qredential.js.org/guide/) | `en` | source |
254
+ | [Português](https://qredential.js.org/pt/guide/) | `pt` | **needs a reviewer** |
255
+ | [Español](https://qredential.js.org/es/guide/) | `es` | **needs a reviewer** |
256
+ | [Français](https://qredential.js.org/fr/guide/) | `fr` | **needs a reviewer** |
257
+ | [Deutsch](https://qredential.js.org/de/guide/) | `de` | **needs a reviewer** |
258
+ | [日本語](https://qredential.js.org/ja/guide/) | `ja` | **needs a reviewer** |
259
+ | [한국어](https://qredential.js.org/ko/guide/) | `ko` | **needs a reviewer** |
260
+ | [简体中文](https://qredential.js.org/zh-Hans/guide/) | `zh-Hans` | **needs a reviewer** |
261
+ | [繁體中文](https://qredential.js.org/zh-Hant/guide/) | `zh-Hant` | **needs a reviewer** |
262
+
263
+ If you speak one of the languages marked as needing a reviewer, you know something I cannot. Reading
264
+ one page and saying whether it is sound is the most useful thing a speaker of that language can do
265
+ here, and there is an
266
+ [issue template](https://github.com/george-veras/qredential/issues/new/choose) for reporting a
267
+ sentence that is wrong, awkward, or uses a term nobody actually uses. Rough reports are welcome; you
268
+ do not need to propose the fix.
269
+
270
+ Anyone who reviews a language is credited on the page and in the release notes, and that language
271
+ stops being marked unreviewed.
272
+
273
+ Translations live in `content/`: the guide as Markdown in `guide/<code>.md`, the landing and the
274
+ playground as key catalogues in `landing/<code>.json` and `playground/<code>.json`. Each guide
275
+ translation records the hash of the English it was made from, so when the English moves and a
276
+ translation does not, the build says so, the page shows a warning, and CI reports it. A catalogue
277
+ missing a key fails the build outright, because a page half in one language is worse than one that
278
+ is simply not translated. Nothing rots quietly.
279
+
280
+ ## Contributing
281
+
282
+ The most useful thing you can do is point this library at a credential from somewhere else and tell
283
+ me what happened. Every test here verifies something this library itself produced, so none of them
284
+ can find a place where it is self-consistent and still wrong. Your credential can, and there is an
285
+ [issue template](https://github.com/george-veras/qredential/issues/new/choose) for exactly that.
286
+
287
+ Setup is `npm install && npm test`, with no services, no environment variables and no runtime
288
+ dependencies. [CONTRIBUTING.md](CONTRIBUTING.md) has the architecture map, the two invariants the
289
+ design rests on, what changes to the verification path need, and a section called **what this
290
+ project will say no to**, which is there so a no reaches you before you build something rather than
291
+ after.
292
+
293
+ There are [good first issues](https://github.com/george-veras/qredential/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
294
+ open, each one written with enough context to start without asking me anything: what to change,
295
+ which file, how to know it worked, and which section of the RFC settles the question.
296
+
297
+ Questions go in [Discussions](https://github.com/george-veras/qredential/discussions) and are not a
298
+ bother. A question the documentation cannot answer is a documentation bug.
299
+
300
+ ## Status
301
+
302
+ Early. The API above is implemented and tested, the shape may still move before 1.0, and I would
303
+ rather hear that a design is wrong now than after people depend on it. Issues welcome.
304
+
305
+ ## License
306
+
307
+ MIT
@@ -0,0 +1,11 @@
1
+ /**
2
+ * base45, RFC 9285.
3
+ *
4
+ * The reason this exists instead of base64: QR codes have a dedicated alphanumeric mode that packs
5
+ * 2 characters into 11 bits, but its alphabet is only 45 characters and does not include lowercase.
6
+ * base64 forces the encoder into byte mode, which costs 8 bits per character. Encoding the same
7
+ * payload as base45 and letting the QR encoder use alphanumeric mode is meaningfully smaller in
8
+ * practice, which is why the EU covid certificate used it.
9
+ */
10
+ export declare function encodeBase45(bytes: Uint8Array): string;
11
+ export declare function decodeBase45(text: string): Uint8Array;
package/dist/base45.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * base45, RFC 9285.
3
+ *
4
+ * The reason this exists instead of base64: QR codes have a dedicated alphanumeric mode that packs
5
+ * 2 characters into 11 bits, but its alphabet is only 45 characters and does not include lowercase.
6
+ * base64 forces the encoder into byte mode, which costs 8 bits per character. Encoding the same
7
+ * payload as base45 and letting the QR encoder use alphanumeric mode is meaningfully smaller in
8
+ * practice, which is why the EU covid certificate used it.
9
+ */
10
+ import { QredentialError } from './errors.js';
11
+ const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
12
+ const REVERSE = {};
13
+ for (let i = 0; i < ALPHABET.length; i++)
14
+ REVERSE[ALPHABET[i]] = i;
15
+ export function encodeBase45(bytes) {
16
+ let out = '';
17
+ let i = 0;
18
+ for (; i + 1 < bytes.length; i += 2) {
19
+ // Two bytes become a number below 65536, which always fits in three base45 digits.
20
+ const n = bytes[i] * 256 + bytes[i + 1];
21
+ out += ALPHABET[n % 45];
22
+ out += ALPHABET[Math.floor(n / 45) % 45];
23
+ out += ALPHABET[Math.floor(n / 2025)];
24
+ }
25
+ if (i < bytes.length) {
26
+ // A single trailing byte is below 256 and takes two digits.
27
+ const n = bytes[i];
28
+ out += ALPHABET[n % 45];
29
+ out += ALPHABET[Math.floor(n / 45)];
30
+ }
31
+ return out;
32
+ }
33
+ export function decodeBase45(text) {
34
+ const values = [];
35
+ for (const ch of text) {
36
+ const v = REVERSE[ch];
37
+ if (v === undefined) {
38
+ throw new QredentialError('invalid_encoding', `invalid base45 character: ${JSON.stringify(ch)}`);
39
+ }
40
+ values.push(v);
41
+ }
42
+ const remainder = values.length % 3;
43
+ if (remainder === 1)
44
+ throw new QredentialError('invalid_encoding', 'invalid base45 length');
45
+ const out = [];
46
+ let i = 0;
47
+ for (; i + 2 < values.length; i += 3) {
48
+ const n = values[i] + values[i + 1] * 45 + values[i + 2] * 2025;
49
+ if (n > 0xffff) {
50
+ throw new QredentialError('invalid_encoding', 'invalid base45 triplet: value exceeds two bytes');
51
+ }
52
+ out.push(n >> 8, n & 0xff);
53
+ }
54
+ if (remainder === 2) {
55
+ const n = values[i] + values[i + 1] * 45;
56
+ if (n > 0xff) {
57
+ throw new QredentialError('invalid_encoding', 'invalid base45 pair: value exceeds one byte');
58
+ }
59
+ out.push(n);
60
+ }
61
+ return new Uint8Array(out);
62
+ }
@@ -0,0 +1,10 @@
1
+ /** Encoding helpers. Everything here is platform neutral: no Buffer, no Node imports. */
2
+ export declare function utf8(s: string): Uint8Array;
3
+ export declare function fromUtf8(b: Uint8Array): string;
4
+ export declare function b64url(bytes: Uint8Array): string;
5
+ export declare function unb64url(s: string): Uint8Array;
6
+ /** JSON in, base64url out. Used for JWT segments and SD-JWT disclosures. */
7
+ export declare function b64urlJson(value: unknown): string;
8
+ export declare function unb64urlJson<T = unknown>(s: string): T;
9
+ export declare function randomBytes(n: number): Uint8Array;
10
+ export declare function concat(...parts: Uint8Array[]): Uint8Array;
package/dist/bytes.js ADDED
@@ -0,0 +1,73 @@
1
+ /** Encoding helpers. Everything here is platform neutral: no Buffer, no Node imports. */
2
+ import { QredentialError } from './errors.js';
3
+ const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
4
+ export function utf8(s) {
5
+ return new TextEncoder().encode(s);
6
+ }
7
+ export function fromUtf8(b) {
8
+ return new TextDecoder().decode(b);
9
+ }
10
+ export function b64url(bytes) {
11
+ let out = '';
12
+ for (let i = 0; i < bytes.length; i += 3) {
13
+ const a = bytes[i];
14
+ const b = bytes[i + 1];
15
+ const c = bytes[i + 2];
16
+ out += B64URL[a >> 2];
17
+ out += B64URL[((a & 3) << 4) | ((b ?? 0) >> 4)];
18
+ if (b === undefined)
19
+ break;
20
+ out += B64URL[((b & 15) << 2) | ((c ?? 0) >> 6)];
21
+ if (c === undefined)
22
+ break;
23
+ out += B64URL[c & 63];
24
+ }
25
+ return out;
26
+ }
27
+ export function unb64url(s) {
28
+ const out = new Uint8Array(Math.floor((s.length * 3) / 4));
29
+ let acc = 0;
30
+ let bits = 0;
31
+ let n = 0;
32
+ for (const ch of s) {
33
+ const v = B64URL.indexOf(ch);
34
+ if (v < 0)
35
+ throw new QredentialError('invalid_encoding', `invalid base64url character: ${ch}`);
36
+ acc = (acc << 6) | v;
37
+ bits += 6;
38
+ if (bits >= 8) {
39
+ bits -= 8;
40
+ out[n++] = (acc >> bits) & 0xff;
41
+ }
42
+ }
43
+ return out.subarray(0, n);
44
+ }
45
+ /** JSON in, base64url out. Used for JWT segments and SD-JWT disclosures. */
46
+ export function b64urlJson(value) {
47
+ return b64url(utf8(JSON.stringify(value)));
48
+ }
49
+ export function unb64urlJson(s) {
50
+ const text = fromUtf8(unb64url(s));
51
+ try {
52
+ return JSON.parse(text);
53
+ }
54
+ catch (error) {
55
+ // JSON.parse throws SyntaxError, which is not this library's error and is documented nowhere.
56
+ throw new QredentialError('invalid_encoding', 'segment is not valid JSON', { cause: error });
57
+ }
58
+ }
59
+ export function randomBytes(n) {
60
+ const b = new Uint8Array(n);
61
+ crypto.getRandomValues(b);
62
+ return b;
63
+ }
64
+ export function concat(...parts) {
65
+ const total = parts.reduce((s, p) => s + p.length, 0);
66
+ const out = new Uint8Array(total);
67
+ let at = 0;
68
+ for (const p of parts) {
69
+ out.set(p, at);
70
+ at += p.length;
71
+ }
72
+ return out;
73
+ }
@@ -0,0 +1,8 @@
1
+ export declare function hasCompression(): boolean;
2
+ export declare function deflate(bytes: Uint8Array, format?: CompressionFormat): Promise<Uint8Array>;
3
+ export declare function inflate(bytes: Uint8Array, format?: CompressionFormat): Promise<Uint8Array>;
4
+ /**
5
+ * Status list publishers are inconsistent about whether the bitstring carries a zlib header, so
6
+ * try both rather than failing on a credential that is actually fine.
7
+ */
8
+ export declare function inflateEither(bytes: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,56 @@
1
+ import { concat } from './bytes.js';
2
+ export function hasCompression() {
3
+ return typeof CompressionStream !== 'undefined' && typeof DecompressionStream !== 'undefined';
4
+ }
5
+ async function drain(stream) {
6
+ const chunks = [];
7
+ const reader = stream.getReader();
8
+ for (;;) {
9
+ const { done, value } = await reader.read();
10
+ if (done)
11
+ break;
12
+ if (value)
13
+ chunks.push(value);
14
+ }
15
+ return concat(...chunks);
16
+ }
17
+ /**
18
+ * Feed a transform stream and collect what comes out.
19
+ *
20
+ * The subtlety is the writable side. When the stream errors on malformed input, the write and close
21
+ * promises reject too. Leaving them floating turns hostile input into an unhandled rejection, which
22
+ * modern Node treats as fatal: a verifier that takes the process down with it is worse than one
23
+ * that returns false. They are settled here, while the real error still surfaces through the
24
+ * readable side, where the caller is waiting for it.
25
+ */
26
+ async function run(transform, bytes) {
27
+ const writer = transform.writable.getWriter();
28
+ const pumped = writer
29
+ .write(bytes)
30
+ .then(() => writer.close())
31
+ .catch(() => undefined);
32
+ try {
33
+ return await drain(transform.readable);
34
+ }
35
+ finally {
36
+ await pumped;
37
+ }
38
+ }
39
+ export async function deflate(bytes, format = 'deflate-raw') {
40
+ return run(new CompressionStream(format), bytes);
41
+ }
42
+ export async function inflate(bytes, format = 'deflate-raw') {
43
+ return run(new DecompressionStream(format), bytes);
44
+ }
45
+ /**
46
+ * Status list publishers are inconsistent about whether the bitstring carries a zlib header, so
47
+ * try both rather than failing on a credential that is actually fine.
48
+ */
49
+ export async function inflateEither(bytes) {
50
+ try {
51
+ return await inflate(bytes, 'deflate');
52
+ }
53
+ catch {
54
+ return await inflate(bytes, 'deflate-raw');
55
+ }
56
+ }
@@ -0,0 +1,13 @@
1
+ import type { Alg, Jwk } from './types.js';
2
+ export declare function importPrivateKey(jwk: Jwk, alg: Alg): Promise<CryptoKey>;
3
+ export declare function importPublicKey(jwk: Jwk, alg: Alg): Promise<CryptoKey>;
4
+ export declare function sign(data: string, key: CryptoKey, alg: Alg): Promise<Uint8Array>;
5
+ export declare function verifySignature(data: string, signature: string, key: CryptoKey, alg: Alg): Promise<boolean>;
6
+ /**
7
+ * Work out which algorithm a key is for.
8
+ *
9
+ * The JWK's own `alg` wins when it is there. Otherwise the curve decides, because a P-256 key can
10
+ * only be used one way here and guessing wrong fails loudly at import rather than quietly.
11
+ */
12
+ export declare function algForJwk(jwk: Jwk): Alg;
13
+ export declare function sha256(data: Uint8Array): Promise<Uint8Array>;
package/dist/crypto.js ADDED
@@ -0,0 +1,72 @@
1
+ import { unb64url, utf8 } from './bytes.js';
2
+ import { QredentialError, asCryptoFailure } from './errors.js';
3
+ function params(alg) {
4
+ switch (alg) {
5
+ case 'ES256':
6
+ return {
7
+ import: { name: 'ECDSA', namedCurve: 'P-256' },
8
+ sign: { name: 'ECDSA', hash: 'SHA-256' },
9
+ };
10
+ case 'EdDSA':
11
+ return { import: { name: 'Ed25519' }, sign: { name: 'Ed25519' } };
12
+ default: {
13
+ const never = alg;
14
+ throw new QredentialError('unsupported_alg', `unsupported algorithm: ${String(never)}`);
15
+ }
16
+ }
17
+ }
18
+ export async function importPrivateKey(jwk, alg) {
19
+ try {
20
+ return await crypto.subtle.importKey('jwk', jwk, params(alg).import, false, ['sign']);
21
+ }
22
+ catch (error) {
23
+ throw asCryptoFailure(`could not import the ${alg} private key`, error);
24
+ }
25
+ }
26
+ export async function importPublicKey(jwk, alg) {
27
+ const pub = { ...jwk };
28
+ delete pub.d;
29
+ pub.key_ops = ['verify'];
30
+ try {
31
+ return await crypto.subtle.importKey('jwk', pub, params(alg).import, false, ['verify']);
32
+ }
33
+ catch (error) {
34
+ throw asCryptoFailure(`could not import the ${alg} public key`, error);
35
+ }
36
+ }
37
+ export async function sign(data, key, alg) {
38
+ try {
39
+ const sig = await crypto.subtle.sign(params(alg).sign, key, utf8(data));
40
+ return new Uint8Array(sig);
41
+ }
42
+ catch (error) {
43
+ throw asCryptoFailure(`could not sign with ${alg}`, error);
44
+ }
45
+ }
46
+ export async function verifySignature(data, signature, key, alg) {
47
+ try {
48
+ return await crypto.subtle.verify(params(alg).sign, key, unb64url(signature), utf8(data));
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Work out which algorithm a key is for.
56
+ *
57
+ * The JWK's own `alg` wins when it is there. Otherwise the curve decides, because a P-256 key can
58
+ * only be used one way here and guessing wrong fails loudly at import rather than quietly.
59
+ */
60
+ export function algForJwk(jwk) {
61
+ if (jwk.alg === 'ES256' || jwk.alg === 'EdDSA')
62
+ return jwk.alg;
63
+ if (jwk.crv === 'P-256')
64
+ return 'ES256';
65
+ if (jwk.crv === 'Ed25519')
66
+ return 'EdDSA';
67
+ throw new QredentialError('unsupported_alg', `cannot tell which algorithm this key is for: kty ${String(jwk.kty)}, crv ${String(jwk.crv)}`);
68
+ }
69
+ export async function sha256(data) {
70
+ const buf = await crypto.subtle.digest('SHA-256', data);
71
+ return new Uint8Array(buf);
72
+ }
@@ -0,0 +1,3 @@
1
+ /** Accepts seconds as a number, or a short form like '30d', '12h', '90s'. */
2
+ export declare function seconds(value: number | string): number;
3
+ export declare function nowSeconds(): number;
@@ -0,0 +1,24 @@
1
+ import { QredentialError } from './errors.js';
2
+ const UNITS = {
3
+ s: 1,
4
+ m: 60,
5
+ h: 3600,
6
+ d: 86400,
7
+ w: 604800,
8
+ y: 31557600,
9
+ };
10
+ /** Accepts seconds as a number, or a short form like '30d', '12h', '90s'. */
11
+ export function seconds(value) {
12
+ if (typeof value === 'number') {
13
+ if (!Number.isFinite(value))
14
+ throw new QredentialError('invalid_option', `invalid duration: ${value}`);
15
+ return Math.floor(value);
16
+ }
17
+ const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w|y)$/.exec(value.trim());
18
+ if (!match)
19
+ throw new QredentialError('invalid_option', `invalid duration: ${JSON.stringify(value)}`);
20
+ return Math.floor(Number(match[1]) * UNITS[match[2]]);
21
+ }
22
+ export function nowSeconds() {
23
+ return Math.floor(Date.now() / 1000);
24
+ }