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/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
|
+
[](https://github.com/george-veras/qredential/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/qredential)
|
|
5
|
+
[](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
|
package/dist/base45.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/bytes.d.ts
ADDED
|
@@ -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>;
|
package/dist/compress.js
ADDED
|
@@ -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
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/duration.js
ADDED
|
@@ -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
|
+
}
|