@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 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
+ }