@sdxc/crypto 0.0.0-pre.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/LICENSE.md +21 -0
- package/README.md +490 -0
- package/dist/encoding.d.ts +109 -0
- package/dist/encoding.js +244 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.js +124 -0
- package/dist/hash.d.ts +52 -0
- package/dist/hash.js +77 -0
- package/dist/hmac.d.ts +65 -0
- package/dist/hmac.js +75 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/lib/base32.d.ts +33 -0
- package/dist/lib/base32.js +70 -0
- package/dist/lib/bytes.d.ts +45 -0
- package/dist/lib/bytes.js +44 -0
- package/dist/password.d.ts +61 -0
- package/dist/password.js +163 -0
- package/dist/random.d.ts +52 -0
- package/dist/random.js +50 -0
- package/dist/seal.d.ts +50 -0
- package/dist/seal.js +114 -0
- package/dist/timing-safe-equal.d.ts +24 -0
- package/dist/timing-safe-equal.js +34 -0
- package/dist/totp.d.ts +147 -0
- package/dist/totp.js +213 -0
- package/package.json +22 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
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,490 @@
|
|
|
1
|
+
# @sdxc/crypto
|
|
2
|
+
|
|
3
|
+
WebCrypto primitives — encoding, digests, HMAC, tokens, password hashing, TOTP, and authenticated encryption — with `Result`-based errors.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Cryptographic code goes wrong in small, repeatable ways: hex encoders that differ in letter case, base64 that breaks in a URL, comparisons that leak how many bytes matched, hashes stored without the parameters they were made with. This package implements each of those pieces once so no call site has to re-derive them.
|
|
8
|
+
|
|
9
|
+
Everything runs on the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), through `crypto.subtle` and `crypto.getRandomValues` only. There is **no `node:crypto` import** anywhere in the package and **no third-party crypto dependency** — the only runtime dependency is [`@sdxc/result`](/packages/result). That keeps it usable on any WebCrypto runtime without a Node compatibility layer, and keeps the most security-sensitive code path free of supply-chain surface.
|
|
10
|
+
|
|
11
|
+
Every asynchronous operation, and every decode that can fail, returns a `Result` instead of throwing: a failed decryption, a malformed stored hash, and an unsupported algorithm are values you handle, not exceptions you remember to catch. Error messages carry only the shape of the problem — never a secret, a hash, or ciphertext.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Encoding And Digests
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { Hex, Base64, Base64Url, sha256 } from "@sdxc/crypto";
|
|
19
|
+
import { isSuccess } from "@sdxc/result";
|
|
20
|
+
|
|
21
|
+
let digest = await sha256(apiKey);
|
|
22
|
+
if (isSuccess(digest)) {
|
|
23
|
+
let lookupHash = Hex.encode(digest.data); // "9f86d081..."
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Base64Url.encode(new Uint8Array([255, 224])); // "_-A", no padding
|
|
27
|
+
Base64.encode("Aladdin:open sesame"); // "QWxhZGRpbjpvcGVuIHNlc2FtZQ==", padded
|
|
28
|
+
Hex.decode("zz"); // failure(InvalidEncodingError)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Signing And Verifying A Payload
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { hmac } from "@sdxc/crypto";
|
|
35
|
+
import { unwrap } from "@sdxc/result";
|
|
36
|
+
|
|
37
|
+
let signature = await hmac.sign(secret, body);
|
|
38
|
+
let valid = unwrap(await hmac.verify(secret, body, request.headers.get("x-signature") ?? ""));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Tokens
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { randomBytes, randomToken } from "@sdxc/crypto";
|
|
45
|
+
|
|
46
|
+
randomBytes(12); // Uint8Array(12)
|
|
47
|
+
randomToken(); // 43 base64url characters, 256 bits of entropy
|
|
48
|
+
randomToken({ bytes: 32, prefix: "sk" }); // "sk_9f1..." — greppable and revocable
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Passwords
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { password } from "@sdxc/crypto";
|
|
55
|
+
import { unwrap } from "@sdxc/result";
|
|
56
|
+
|
|
57
|
+
let stored = unwrap(await password.hash(form.password));
|
|
58
|
+
// "$pbkdf2-sha256$i=600000$<salt>$<key>"
|
|
59
|
+
|
|
60
|
+
let valid = unwrap(await password.verify(stored, form.password));
|
|
61
|
+
if (valid && password.needsRehash(stored)) {
|
|
62
|
+
stored = unwrap(await password.hash(form.password));
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Second Factor
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { totp } from "@sdxc/crypto";
|
|
70
|
+
import { unwrap } from "@sdxc/result";
|
|
71
|
+
|
|
72
|
+
let secret = totp.generateSecret();
|
|
73
|
+
let uri = totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
74
|
+
let valid = unwrap(await totp.verify(secret, form.code, { window: 1 }));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Encryption At Rest
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { importKey, seal, open } from "@sdxc/crypto";
|
|
81
|
+
import { unwrap } from "@sdxc/result";
|
|
82
|
+
|
|
83
|
+
let key = unwrap(await importKey(env.SEAL_KEY));
|
|
84
|
+
let sealed = unwrap(await seal(key, refreshToken)); // "v1.<iv>.<ciphertext>"
|
|
85
|
+
let plaintext = unwrap(await open(key, sealed));
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### Encoding
|
|
91
|
+
|
|
92
|
+
#### `Hex.encode(data: BinaryLike): string`
|
|
93
|
+
|
|
94
|
+
Encodes bytes as lowercase hexadecimal, two characters per byte. Strings are read as UTF-8.
|
|
95
|
+
|
|
96
|
+
**Parameters:**
|
|
97
|
+
|
|
98
|
+
- `data`: Text or binary payload
|
|
99
|
+
|
|
100
|
+
**Returns:**
|
|
101
|
+
|
|
102
|
+
- Lowercase hex string
|
|
103
|
+
|
|
104
|
+
**Example:**
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
Hex.encode(new Uint8Array([0, 255])); // "00ff"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### `Hex.decode(text: string): Result<Bytes, InvalidEncodingError>`
|
|
111
|
+
|
|
112
|
+
Decodes a hex string, accepting either letter case. An odd length or a non-hex character fails instead of decoding partially, so a truncated signature can never compare equal to a prefix.
|
|
113
|
+
|
|
114
|
+
**Parameters:**
|
|
115
|
+
|
|
116
|
+
- `text`: Hex string
|
|
117
|
+
|
|
118
|
+
**Returns:**
|
|
119
|
+
|
|
120
|
+
- Decoded bytes, or `InvalidEncodingError`
|
|
121
|
+
|
|
122
|
+
#### `Base64Url.encode(data: BinaryLike): string`
|
|
123
|
+
|
|
124
|
+
Encodes bytes as base64url without `=` padding, using only `A-Z`, `a-z`, `0-9`, `-`, and `_`. Safe in URLs, headers, and file names.
|
|
125
|
+
|
|
126
|
+
#### `Base64Url.decode(text: string): Result<Bytes, InvalidEncodingError>`
|
|
127
|
+
|
|
128
|
+
Decodes base64url text with or without padding. The URL-safe alphabet is the whole accepted input set, and a short final group's leftover bits must be zero, so two accepted strings decode to the same bytes exactly when they differ only in trailing `=`.
|
|
129
|
+
|
|
130
|
+
#### `Base64.encode(data: BinaryLike): string`
|
|
131
|
+
|
|
132
|
+
Encodes bytes as standard base64 with `=` padding, using `A-Z`, `a-z`, `0-9`, `+`, and `/` — the alphabet RFC 4648 §4 defines. Strings are encoded as UTF-8 first, so a payload outside Latin-1 travels as the octets a peer decodes it back from, which is what the `user:password` credentials of HTTP Basic authentication require (RFC 7617 §2.1).
|
|
133
|
+
|
|
134
|
+
**Parameters:**
|
|
135
|
+
|
|
136
|
+
- `data`: Text or binary payload
|
|
137
|
+
|
|
138
|
+
**Returns:**
|
|
139
|
+
|
|
140
|
+
- Padded base64 string, always a multiple of four characters
|
|
141
|
+
|
|
142
|
+
**Example:**
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
Base64.encode("Aladdin:open sesame"); // "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
|
|
146
|
+
Base64.encode(new Uint8Array([251, 255])); // "+/8="
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### `Base64.decode(text: string): Result<Bytes, InvalidEncodingError>`
|
|
150
|
+
|
|
151
|
+
Decodes standard base64 text carrying its full padding. The standard alphabet at a padded length is the whole accepted input set, and a short final group's leftover bits must be zero, so one byte string has exactly one accepted spelling.
|
|
152
|
+
|
|
153
|
+
**Parameters:**
|
|
154
|
+
|
|
155
|
+
- `text`: Padded base64 string
|
|
156
|
+
|
|
157
|
+
**Returns:**
|
|
158
|
+
|
|
159
|
+
- Decoded bytes, or `InvalidEncodingError`
|
|
160
|
+
|
|
161
|
+
### Hashing And HMAC
|
|
162
|
+
|
|
163
|
+
#### `sha256(data: BinaryLike): Promise<Result<Bytes, CryptoError>>`
|
|
164
|
+
|
|
165
|
+
Hashes a payload with SHA-256. Deterministic and unsalted, which makes it right for lookups and fingerprints — an API key stored as a digest can still be found by hashing the presented key — and wrong for passwords.
|
|
166
|
+
|
|
167
|
+
**Example:**
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
let digest = unwrap(await sha256(apiKey));
|
|
171
|
+
let row = await findByKeyHash(Hex.encode(digest));
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### `sha384(data: BinaryLike): Promise<Result<Bytes, CryptoError>>`
|
|
175
|
+
|
|
176
|
+
Hashes a payload with SHA-384, returning 48 bytes.
|
|
177
|
+
|
|
178
|
+
#### `sha512(data: BinaryLike): Promise<Result<Bytes, CryptoError>>`
|
|
179
|
+
|
|
180
|
+
Hashes a payload with SHA-512, returning 64 bytes.
|
|
181
|
+
|
|
182
|
+
The three digests share one signature, so a protocol that picks its hash at runtime can select from a map built at the call site. OpenID Connect token hash claims (`at_hash`, `c_hash`, `s_hash`) work that way: the digest follows the ID token's `alg`, where `*256` means SHA-256, `*384` SHA-384, and `*512` SHA-512 (OpenID Connect Core §3.1.3.6).
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
let digests = { "SHA-256": sha256, "SHA-384": sha384, "SHA-512": sha512 };
|
|
186
|
+
let digest = unwrap(await digests[hashFor(header.alg)](accessToken));
|
|
187
|
+
let atHash = Base64Url.encode(digest.subarray(0, digest.length / 2));
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `hmac.sign(secret: BinaryLike, payload: BinaryLike, options?: hmac.Options): Promise<Result<Bytes, CryptoError>>`
|
|
191
|
+
|
|
192
|
+
Signs a payload with a secret.
|
|
193
|
+
|
|
194
|
+
**Parameters:**
|
|
195
|
+
|
|
196
|
+
- `secret`: Key material; text is read as UTF-8
|
|
197
|
+
- `payload`: Message to authenticate
|
|
198
|
+
- `options.hash`: `"SHA-1"`, `"SHA-256"` (default), `"SHA-384"`, or `"SHA-512"`
|
|
199
|
+
|
|
200
|
+
**Returns:**
|
|
201
|
+
|
|
202
|
+
- Raw MAC bytes, or `UnsupportedAlgorithmError` for an unknown hash
|
|
203
|
+
|
|
204
|
+
#### `hmac.verify(secret: BinaryLike, payload: BinaryLike, signature: BinaryLike, options?: hmac.Options): Promise<Result<boolean, CryptoError>>`
|
|
205
|
+
|
|
206
|
+
Recomputes the MAC and compares it in constant time. A `signature` given as a string is decoded as hex — the form `Hex.encode` produces and the form signature headers usually carry. A string that is not hex is reported as a plain mismatch rather than an error, so a malformed header fails closed without a second branch at the call site.
|
|
207
|
+
|
|
208
|
+
**Example:**
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
if (!unwrap(await hmac.verify(secret, body, header))) return new Response(null, { status: 401 });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### `timingSafeEqual(left: BinaryLike, right: BinaryLike): boolean`
|
|
215
|
+
|
|
216
|
+
Compares two values byte for byte with no early exit, so the running time does not depend on where or whether they differ. Lengths are assumed not to be secret: a length mismatch is detectable, only the content is protected.
|
|
217
|
+
|
|
218
|
+
**Example:**
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
if (!timingSafeEqual(expectedVerifier, providedVerifier)) return reject();
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Random Values And Tokens
|
|
225
|
+
|
|
226
|
+
#### `randomBytes(size: number): Bytes`
|
|
227
|
+
|
|
228
|
+
Fills a new buffer with cryptographically strong random bytes.
|
|
229
|
+
|
|
230
|
+
**Parameters:**
|
|
231
|
+
|
|
232
|
+
- `size`: Integer from 0 to 65536, the most `crypto.getRandomValues` fills in one call
|
|
233
|
+
|
|
234
|
+
**Returns:**
|
|
235
|
+
|
|
236
|
+
- A fresh buffer of exactly `size` bytes
|
|
237
|
+
|
|
238
|
+
Throws `RangeError` for a size the runtime cannot fill — a programming mistake rather than a runtime condition.
|
|
239
|
+
|
|
240
|
+
#### `randomToken(options?: randomToken.Options): string`
|
|
241
|
+
|
|
242
|
+
Generates a URL-safe random token.
|
|
243
|
+
|
|
244
|
+
**Parameters:**
|
|
245
|
+
|
|
246
|
+
- `options.bytes`: Bytes of entropy, default `32`
|
|
247
|
+
- `options.prefix`: Prefix joined with `_`
|
|
248
|
+
|
|
249
|
+
**Returns:**
|
|
250
|
+
|
|
251
|
+
- The token, as `<prefix>_<random>` when a prefix is given
|
|
252
|
+
|
|
253
|
+
The prefix is not entropy. It exists so a token found in a log can be recognized and revoked by kind.
|
|
254
|
+
|
|
255
|
+
**Example:**
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
randomToken({ bytes: 32, prefix: "sk" }); // "sk_..."
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Password Hashing
|
|
262
|
+
|
|
263
|
+
PBKDF2-HMAC-SHA256 through WebCrypto, at **600,000 iterations** with a 16-byte salt and a 32-byte derived key. The iteration count lives in a single module-level constant, so raising it is a one-line change.
|
|
264
|
+
|
|
265
|
+
#### Encoded Format
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
$pbkdf2-sha256$i=600000$<salt>$<key>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
| Field | Meaning |
|
|
272
|
+
| --------------- | ----------------------------------------------- |
|
|
273
|
+
| `pbkdf2-sha256` | Algorithm tag; anything else is not this format |
|
|
274
|
+
| `i=600000` | Iteration count the hash was produced with |
|
|
275
|
+
| `<salt>` | Random salt, unpadded base64url |
|
|
276
|
+
| `<key>` | Derived key, unpadded base64url |
|
|
277
|
+
|
|
278
|
+
The format is self-describing: each hash carries the parameters it was made with, so verification uses the stored parameters and the current policy can be raised without a schema change or a mass reset.
|
|
279
|
+
|
|
280
|
+
#### `password.hash(secret: string): Promise<Result<string, CryptoError>>`
|
|
281
|
+
|
|
282
|
+
Hashes a password with the current policy and a fresh random salt. The same password hashes differently every time.
|
|
283
|
+
|
|
284
|
+
#### `password.verify(stored: string, secret: string): Promise<Result<boolean, CryptoError>>`
|
|
285
|
+
|
|
286
|
+
Checks a password against an encoded hash using the hash's own parameters. A wrong password is `success(false)`; only an unusable stored value or a runtime failure is a `Failure`, which keeps "wrong password" and "cannot check" apart.
|
|
287
|
+
|
|
288
|
+
**Returns:**
|
|
289
|
+
|
|
290
|
+
- `success(boolean)` for a readable hash
|
|
291
|
+
- `failure(MalformedHashError)` for a value not written in this format, including a bcrypt hash
|
|
292
|
+
- `failure(UnsupportedAlgorithmError)` for a well-formed value with another algorithm tag
|
|
293
|
+
|
|
294
|
+
#### `password.needsRehash(stored: string): boolean`
|
|
295
|
+
|
|
296
|
+
Reports whether a stored hash is behind current policy: a lower iteration count, a shorter salt or key, or a value that cannot be parsed at all. An unparsable value counts as needing a rehash, because a foreign hash is exactly what upgrade-on-login replaces.
|
|
297
|
+
|
|
298
|
+
### TOTP
|
|
299
|
+
|
|
300
|
+
RFC 6238 one-time passwords, verified against the RFC's published test vectors for SHA-1, SHA-256, and SHA-512. Defaults are the ones authenticator apps assume: a 30 second step, 6 digits, SHA-1, and a drift window of one step.
|
|
301
|
+
|
|
302
|
+
#### `totp.generateSecret(options?: totp.SecretOptions): string`
|
|
303
|
+
|
|
304
|
+
Generates a random shared secret as unpadded uppercase base32 — the only encoding authenticator apps accept in a QR code or a typed setup key.
|
|
305
|
+
|
|
306
|
+
**Parameters:**
|
|
307
|
+
|
|
308
|
+
- `options.bytes`: Secret size, default `20` (the 160 bits RFC 4226 recommends)
|
|
309
|
+
|
|
310
|
+
#### `totp.code(secret: string, options?: totp.CodeOptions): Promise<Result<string, CryptoError>>`
|
|
311
|
+
|
|
312
|
+
Generates the code for a secret at a point in time.
|
|
313
|
+
|
|
314
|
+
**Parameters:**
|
|
315
|
+
|
|
316
|
+
- `secret`: Base32 shared secret
|
|
317
|
+
- `options.at`: `Date` or epoch milliseconds, default now
|
|
318
|
+
- `options.step`: Step in seconds, default `30`
|
|
319
|
+
- `options.digits`: Digits in the code, default `6`, at most `10`
|
|
320
|
+
- `options.algorithm`: `"SHA-1"` (default), `"SHA-256"`, or `"SHA-512"`
|
|
321
|
+
|
|
322
|
+
#### `totp.verify(secret: string, code: string, options?: totp.VerifyOptions): Promise<Result<boolean, CryptoError>>`
|
|
323
|
+
|
|
324
|
+
Checks a submitted code against the current step and the drift window. Every step in the window is evaluated even after a match, and each comparison runs in constant time, so neither the total work nor the timing reveals which step matched. A code of the wrong shape is a plain mismatch, not an error.
|
|
325
|
+
|
|
326
|
+
**Parameters:**
|
|
327
|
+
|
|
328
|
+
- `options.window`: Steps accepted on either side of the current one, default `1`
|
|
329
|
+
- Plus every option `totp.code` takes, which must match how the code was generated
|
|
330
|
+
|
|
331
|
+
#### `totp.uri(secret: string, options: totp.UriOptions): string`
|
|
332
|
+
|
|
333
|
+
Builds the `otpauth://` URI an authenticator app scans during enrollment.
|
|
334
|
+
|
|
335
|
+
**Parameters:**
|
|
336
|
+
|
|
337
|
+
- `options.issuer`: Service name, used in both the label and the query
|
|
338
|
+
- `options.account`: Account identifier, usually an email address
|
|
339
|
+
- `options.digits`, `options.step`, `options.algorithm`: Reflected so the app mirrors them
|
|
340
|
+
|
|
341
|
+
**Example:**
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
345
|
+
// "otpauth://totp/Acme:ada%40example.com?secret=...&issuer=Acme&algorithm=SHA1&digits=6&period=30"
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Symmetric Encryption
|
|
349
|
+
|
|
350
|
+
AES-GCM with a random 96-bit IV per call, wrapped in a versioned envelope so an algorithm change never requires guessing the format of stored data:
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
v1.<iv>.<ciphertext>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### `importKey(raw: string): Promise<Result<CryptoKey, CryptoError>>`
|
|
357
|
+
|
|
358
|
+
Imports raw base64url key material as a non-extractable AES-GCM key, so a leaked reference cannot be turned back into bytes.
|
|
359
|
+
|
|
360
|
+
**Parameters:**
|
|
361
|
+
|
|
362
|
+
- `raw`: Base64url-encoded key of 16, 24, or 32 bytes
|
|
363
|
+
|
|
364
|
+
**Returns:**
|
|
365
|
+
|
|
366
|
+
- The key, `InvalidKeyError` for the wrong size or rejected material, or `InvalidEncodingError` when the string is not base64url
|
|
367
|
+
|
|
368
|
+
Generate material with `randomToken({ bytes: 32 })`.
|
|
369
|
+
|
|
370
|
+
#### `seal(key: CryptoKey, plaintext: string): Promise<Result<string, CryptoError>>`
|
|
371
|
+
|
|
372
|
+
Encrypts a string into an envelope. The IV is random per call, so sealing the same plaintext twice yields different envelopes.
|
|
373
|
+
|
|
374
|
+
#### `open(key: CryptoKey, sealed: string): Promise<Result<string, CryptoError>>`
|
|
375
|
+
|
|
376
|
+
Decrypts an envelope produced by `seal`.
|
|
377
|
+
|
|
378
|
+
**Returns:**
|
|
379
|
+
|
|
380
|
+
- The plaintext, `InvalidEnvelopeError` for a malformed or unknown-version envelope, or `DecryptionError` when authentication fails
|
|
381
|
+
|
|
382
|
+
A wrong key and a tampered ciphertext produce the same `DecryptionError` with the same message, so failures cannot be used to probe which part of the value was altered.
|
|
383
|
+
|
|
384
|
+
### Errors
|
|
385
|
+
|
|
386
|
+
Every failure extends `CryptoError`, so one `instanceof` check covers the package while the subclasses let callers branch on the cause.
|
|
387
|
+
|
|
388
|
+
| Error | Raised when |
|
|
389
|
+
| --------------------------- | ---------------------------------------------------------- |
|
|
390
|
+
| `CryptoError` | Base class, and unexpected WebCrypto failures |
|
|
391
|
+
| `InvalidEncodingError` | A string is not valid hex, base64, base64url, or base32 |
|
|
392
|
+
| `MalformedHashError` | A stored password hash does not follow the encoded format |
|
|
393
|
+
| `UnsupportedAlgorithmError` | An algorithm identifier is valid but not supported here |
|
|
394
|
+
| `InvalidKeyError` | Key material is the wrong size or rejected by the runtime |
|
|
395
|
+
| `InvalidEnvelopeError` | A sealed value does not match the versioned envelope |
|
|
396
|
+
| `DecryptionError` | Authenticated decryption failed for a well-formed envelope |
|
|
397
|
+
|
|
398
|
+
No error message contains a secret, a hash, or ciphertext. Algorithm identifiers read back from stored values are sanitized to a short tag before they reach a message.
|
|
399
|
+
|
|
400
|
+
### Types
|
|
401
|
+
|
|
402
|
+
#### `BinaryLike`
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
type BinaryLike = string | Uint8Array | ArrayBuffer;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Accepted wherever the package takes "bytes". Strings are read as UTF-8.
|
|
409
|
+
|
|
410
|
+
#### `Bytes`
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
type Bytes = Uint8Array<ArrayBuffer>;
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Returned wherever the package produces bytes. WebCrypto refuses views backed by a `SharedArrayBuffer`, so output is always in a form that can be fed straight back in.
|
|
417
|
+
|
|
418
|
+
## Pattern: Upgrade-On-Login
|
|
419
|
+
|
|
420
|
+
Verify with the parameters the stored hash records, then re-hash with current policy once the password is known to be correct. Raising `PBKDF2_ITERATIONS` starts migrating accounts on their next login, with no mass reset.
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
let valid = await password.verify(user.passwordHash, form.password);
|
|
424
|
+
if (isFailure(valid) || !valid.data) return unauthorized();
|
|
425
|
+
|
|
426
|
+
if (password.needsRehash(user.passwordHash)) {
|
|
427
|
+
let rehashed = await password.hash(form.password);
|
|
428
|
+
if (isSuccess(rehashed)) await updatePasswordHash(user.id, rehashed.data);
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Pattern: Hashed Lookup, Sealed Storage
|
|
433
|
+
|
|
434
|
+
Sealed values are not comparable and not searchable, because the IV changes every time. Anything that must be looked up stays hashed; anything that must be read back gets sealed. A credential that needs both gets both columns.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
let token = randomToken({ bytes: 32, prefix: "sk" });
|
|
438
|
+
|
|
439
|
+
await store({
|
|
440
|
+
lookupHash: Hex.encode(unwrap(await sha256(token))),
|
|
441
|
+
sealedToken: unwrap(await seal(key, token)),
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Pattern: Verifying An Inbound Signature
|
|
446
|
+
|
|
447
|
+
Fail closed when the signing secret is missing, and let a malformed header fall through as a mismatch rather than a separate branch.
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
let secret = env.WEBHOOK_SECRET;
|
|
451
|
+
if (!secret) return new Response(null, { status: 500 });
|
|
452
|
+
|
|
453
|
+
let body = await request.text();
|
|
454
|
+
let signature = request.headers.get("x-signature") ?? "";
|
|
455
|
+
|
|
456
|
+
let valid = await hmac.verify(secret, body, signature);
|
|
457
|
+
if (isFailure(valid) || !valid.data) return new Response(null, { status: 401 });
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Pattern: Enrolling A Second Factor
|
|
461
|
+
|
|
462
|
+
Store the secret, show the URI as a QR code, and only mark the factor as confirmed once the user proves possession with a code.
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
let secret = totp.generateSecret();
|
|
466
|
+
let uri = totp.uri(secret, { issuer: "Acme", account: user.email });
|
|
467
|
+
|
|
468
|
+
// After the user submits a code from their authenticator app:
|
|
469
|
+
let confirmed = await totp.verify(secret, form.code, { window: 1 });
|
|
470
|
+
if (isSuccess(confirmed) && confirmed.data) await enableSecondFactor(user.id, secret);
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Related Packages
|
|
474
|
+
|
|
475
|
+
- [`@sdxc/result`](/packages/result) - `Result` type every operation here returns, with `unwrap`, `isSuccess`, and `match`
|
|
476
|
+
- [`@sdxc/typeid`](/packages/typeid) - Prefixed, sortable identifiers for records, where `randomToken` covers secrets
|
|
477
|
+
|
|
478
|
+
## Tips
|
|
479
|
+
|
|
480
|
+
1. **Hash for lookup, seal for retrieval** - `sha256` is deterministic and searchable; `seal` is neither. Pick by whether the value must be found or read back.
|
|
481
|
+
2. **Pick the codec by where the value travels** - `Base64Url` for URLs, headers, and file names; `Base64` where a spec calls for the standard padded alphabet, such as HTTP Basic credentials. Each decoder accepts only its own alphabet.
|
|
482
|
+
3. **Never use `sha256` for passwords** - it is unsalted and fast, which is the opposite of what a password needs. Use `password.hash`.
|
|
483
|
+
4. **Raise the iteration count in one place** - `PBKDF2_ITERATIONS` in `src/password.ts` is the whole policy; stored hashes keep verifying with the count they recorded.
|
|
484
|
+
5. **Treat `needsRehash` as the migration signal** - it also returns `true` for hashes written by another scheme, which is what makes a legacy compatibility path finite.
|
|
485
|
+
6. **Compare secrets with `timingSafeEqual`** - `===` on a token, a verifier, or a MAC leaks how many bytes matched.
|
|
486
|
+
7. **Prefer `hmac.verify` over comparing signatures yourself** - it recomputes and compares in constant time in one call.
|
|
487
|
+
8. **Keep the seal key out of the code** - read it from the environment, and remember that rotating it means re-sealing every stored value, since the envelope version does not identify the key.
|
|
488
|
+
9. **`randomToken` prefixes pay for themselves** - a leaked `sk_...` in a log is recognizable and revocable without decoding anything.
|
|
489
|
+
10. **Match TOTP options between generation and verification** - a mismatched `digits`, `step`, or `algorithm` fails silently as a wrong code.
|
|
490
|
+
11. **Let failures stay values** - a `Failure` from `open` or `verify` means "could not check", not "invalid". Reject on both, but do not log the value that failed.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex, base64, and base64url codecs used by every other module in this package.
|
|
3
|
+
*
|
|
4
|
+
* Encoding differences (padding, letter case, URL-safe alphabet) turn into
|
|
5
|
+
* interoperability bugs when each call site rewrites them, so all three codecs
|
|
6
|
+
* live here, each with one canonical output shape and a validating decoder.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { BinaryLike, Bytes } from "./lib/bytes.js";
|
|
13
|
+
import { InvalidEncodingError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Lowercase hexadecimal encoding, the canonical text form for digests and MACs.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* Hex.encode(new Uint8Array([255, 0])); // "ff00"
|
|
19
|
+
*/
|
|
20
|
+
export declare class Hex {
|
|
21
|
+
/**
|
|
22
|
+
* Encodes bytes (or UTF-8 text) as lowercase hex.
|
|
23
|
+
*
|
|
24
|
+
* @param data Payload to encode.
|
|
25
|
+
* @returns Hex string, two characters per byte, never padded or uppercased.
|
|
26
|
+
* @example
|
|
27
|
+
* Hex.encode("hi"); // "6869"
|
|
28
|
+
*/
|
|
29
|
+
static encode(data: BinaryLike): string;
|
|
30
|
+
/**
|
|
31
|
+
* Decodes a hex string, accepting either letter case.
|
|
32
|
+
*
|
|
33
|
+
* An odd length or a non-hex character rejects the whole input up front, so a
|
|
34
|
+
* truncated signature always fails verification against a prefix.
|
|
35
|
+
*
|
|
36
|
+
* @param text Hex string to decode.
|
|
37
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not hex.
|
|
38
|
+
* @example
|
|
39
|
+
* Hex.decode("ff00"); // success(Uint8Array [255, 0])
|
|
40
|
+
*/
|
|
41
|
+
static decode(text: string): Result<Bytes, InvalidEncodingError>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Unpadded base64url encoding, safe in URLs, headers, and file names.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* Base64Url.encode(new Uint8Array([251, 255])); // "-_8"
|
|
48
|
+
*/
|
|
49
|
+
export declare class Base64Url {
|
|
50
|
+
/**
|
|
51
|
+
* Encodes bytes (or UTF-8 text) as base64url without `=` padding.
|
|
52
|
+
*
|
|
53
|
+
* Padding is dropped because these values travel in URLs and query strings,
|
|
54
|
+
* where `=` needs escaping; `decode` accepts it back either way.
|
|
55
|
+
*
|
|
56
|
+
* @param data Payload to encode.
|
|
57
|
+
* @returns Base64url string using only `A-Z`, `a-z`, `0-9`, `-`, and `_`.
|
|
58
|
+
* @example
|
|
59
|
+
* Base64Url.encode("hi"); // "aGk"
|
|
60
|
+
*/
|
|
61
|
+
static encode(data: BinaryLike): string;
|
|
62
|
+
/**
|
|
63
|
+
* Decodes base64url text, tolerating present or absent `=` padding.
|
|
64
|
+
*
|
|
65
|
+
* The URL-safe alphabet is the whole accepted input set, and a short final
|
|
66
|
+
* group's leftover bits must be zero, so two accepted strings decode to the same
|
|
67
|
+
* bytes exactly when they differ only in trailing `=`.
|
|
68
|
+
*
|
|
69
|
+
* @param text Base64url string to decode.
|
|
70
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not canonical base64url.
|
|
71
|
+
* @example
|
|
72
|
+
* Base64Url.decode("aGk"); // success(bytes for "hi")
|
|
73
|
+
*/
|
|
74
|
+
static decode(text: string): Result<Bytes, InvalidEncodingError>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Padded standard base64, the alphabet RFC 4648 §4 defines.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* Base64.encode(new Uint8Array([251, 255])); // "+/8="
|
|
81
|
+
*/
|
|
82
|
+
export declare class Base64 {
|
|
83
|
+
/**
|
|
84
|
+
* Encodes bytes (or UTF-8 text) as standard base64 with `=` padding.
|
|
85
|
+
*
|
|
86
|
+
* Text becomes its UTF-8 bytes first, so a payload outside Latin-1 encodes to
|
|
87
|
+
* the octets a peer decodes it back from, as the `user:password` credentials of
|
|
88
|
+
* HTTP Basic authentication require (RFC 7617 §2.1).
|
|
89
|
+
*
|
|
90
|
+
* @param data Payload to encode.
|
|
91
|
+
* @returns Base64 string over `A-Z`, `a-z`, `0-9`, `+`, `/`, padded to a multiple of four characters.
|
|
92
|
+
* @example
|
|
93
|
+
* Base64.encode("Aladdin:open sesame"); // "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
|
|
94
|
+
*/
|
|
95
|
+
static encode(data: BinaryLike): string;
|
|
96
|
+
/**
|
|
97
|
+
* Decodes standard base64 text carrying its full `=` padding.
|
|
98
|
+
*
|
|
99
|
+
* The standard alphabet with full padding is the whole accepted input set, and a
|
|
100
|
+
* short final group's leftover bits must be zero, so one byte string has exactly
|
|
101
|
+
* one accepted spelling.
|
|
102
|
+
*
|
|
103
|
+
* @param text Base64 string to decode.
|
|
104
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not canonical padded base64.
|
|
105
|
+
* @example
|
|
106
|
+
* Base64.decode("aGk="); // success(bytes for "hi")
|
|
107
|
+
*/
|
|
108
|
+
static decode(text: string): Result<Bytes, InvalidEncodingError>;
|
|
109
|
+
}
|