leviathan-crypto 1.0.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/CLAUDE.md +265 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/SECURITY.md +174 -0
- package/dist/chacha.wasm +0 -0
- package/dist/chacha20/index.d.ts +49 -0
- package/dist/chacha20/index.js +177 -0
- package/dist/chacha20/ops.d.ts +16 -0
- package/dist/chacha20/ops.js +146 -0
- package/dist/chacha20/pool.d.ts +52 -0
- package/dist/chacha20/pool.js +188 -0
- package/dist/chacha20/pool.worker.d.ts +1 -0
- package/dist/chacha20/pool.worker.js +37 -0
- package/dist/chacha20/types.d.ts +30 -0
- package/dist/chacha20/types.js +1 -0
- package/dist/docs/architecture.md +795 -0
- package/dist/docs/argon2id.md +290 -0
- package/dist/docs/chacha20.md +602 -0
- package/dist/docs/chacha20_pool.md +306 -0
- package/dist/docs/fortuna.md +322 -0
- package/dist/docs/init.md +308 -0
- package/dist/docs/loader.md +206 -0
- package/dist/docs/serpent.md +914 -0
- package/dist/docs/sha2.md +620 -0
- package/dist/docs/sha3.md +509 -0
- package/dist/docs/types.md +198 -0
- package/dist/docs/utils.md +273 -0
- package/dist/docs/wasm.md +193 -0
- package/dist/embedded/chacha.d.ts +1 -0
- package/dist/embedded/chacha.js +2 -0
- package/dist/embedded/serpent.d.ts +1 -0
- package/dist/embedded/serpent.js +2 -0
- package/dist/embedded/sha2.d.ts +1 -0
- package/dist/embedded/sha2.js +2 -0
- package/dist/embedded/sha3.d.ts +1 -0
- package/dist/embedded/sha3.js +2 -0
- package/dist/fortuna.d.ts +72 -0
- package/dist/fortuna.js +445 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +44 -0
- package/dist/init.d.ts +11 -0
- package/dist/init.js +49 -0
- package/dist/loader.d.ts +4 -0
- package/dist/loader.js +30 -0
- package/dist/serpent/index.d.ts +65 -0
- package/dist/serpent/index.js +242 -0
- package/dist/serpent/seal.d.ts +8 -0
- package/dist/serpent/seal.js +70 -0
- package/dist/serpent/stream-encoder.d.ts +20 -0
- package/dist/serpent/stream-encoder.js +167 -0
- package/dist/serpent/stream-pool.d.ts +48 -0
- package/dist/serpent/stream-pool.js +285 -0
- package/dist/serpent/stream-sealer.d.ts +34 -0
- package/dist/serpent/stream-sealer.js +223 -0
- package/dist/serpent/stream.d.ts +28 -0
- package/dist/serpent/stream.js +205 -0
- package/dist/serpent/stream.worker.d.ts +32 -0
- package/dist/serpent/stream.worker.js +117 -0
- package/dist/serpent/types.d.ts +5 -0
- package/dist/serpent/types.js +1 -0
- package/dist/serpent.wasm +0 -0
- package/dist/sha2/hkdf.d.ts +16 -0
- package/dist/sha2/hkdf.js +108 -0
- package/dist/sha2/index.d.ts +40 -0
- package/dist/sha2/index.js +190 -0
- package/dist/sha2/types.d.ts +5 -0
- package/dist/sha2/types.js +1 -0
- package/dist/sha2.wasm +0 -0
- package/dist/sha3/index.d.ts +55 -0
- package/dist/sha3/index.js +246 -0
- package/dist/sha3/types.d.ts +5 -0
- package/dist/sha3/types.js +1 -0
- package/dist/sha3.wasm +0 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.js +26 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +169 -0
- package/package.json +90 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Argon2id: Memory-Hardened Password Hashing and Key Derivation
|
|
2
|
+
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> leviathan-crypto does not wrap Argon2id. This document covers how to use the
|
|
5
|
+
> [`argon2id`](https://www.npmjs.com/package/argon2id) npm package directly and
|
|
6
|
+
> how to pair it with leviathan primitives for passphrase-based encryption.
|
|
7
|
+
|
|
8
|
+
## Why Argon2id
|
|
9
|
+
|
|
10
|
+
Password hashing is the last line of defense when a database is breached. If an
|
|
11
|
+
attacker obtains hashed passwords, the hash function determines how expensive it
|
|
12
|
+
is to recover each plaintext. Traditional hash functions — even iterated ones
|
|
13
|
+
like PBKDF2 — are fast on GPUs and custom hardware. An attacker with a few
|
|
14
|
+
thousand dollars of GPU hardware can test billions of PBKDF2-SHA256 candidates
|
|
15
|
+
per second. bcrypt improves on this with a 4 KiB memory requirement that limits
|
|
16
|
+
GPU parallelism, but 4 KiB is trivial by modern standards.
|
|
17
|
+
|
|
18
|
+
Argon2 was the winner of the Password Hashing Competition (PHC, 2013–2015),
|
|
19
|
+
selected from 24 submissions after two years of public analysis. It was designed
|
|
20
|
+
specifically to be **memory-hard**, computing the hash requires not just CPU
|
|
21
|
+
time but a large block of RAM that cannot be traded away. An attacker who tries
|
|
22
|
+
to use less memory must perform exponentially more computation, making GPU and
|
|
23
|
+
ASIC attacks economically impractical.
|
|
24
|
+
|
|
25
|
+
Argon2id is the recommended variant (RFC 9106): it uses Argon2i for the first
|
|
26
|
+
pass (resisting side-channel attacks) and Argon2d for subsequent passes
|
|
27
|
+
(resisting GPU attacks). It is the only Argon2 variant you should use for new
|
|
28
|
+
applications.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm i argon2id
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The compiled WASM binaries are included. SIMD acceleration is used automatically
|
|
39
|
+
where available (all modern browsers and Node ≥ 18), with a scalar fallback for
|
|
40
|
+
environments that do not support it. Both produce identical output.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Basic usage
|
|
45
|
+
|
|
46
|
+
With a bundler (Rollup, Webpack, Vite):
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import loadArgon2idWasm from 'argon2id';
|
|
50
|
+
|
|
51
|
+
const argon2id = await loadArgon2idWasm();
|
|
52
|
+
|
|
53
|
+
const hash = argon2id({
|
|
54
|
+
password: new TextEncoder().encode('hunter2'),
|
|
55
|
+
salt: crypto.getRandomValues(new Uint8Array(16)),
|
|
56
|
+
passes: 2,
|
|
57
|
+
memorySize: 19456, // KiB
|
|
58
|
+
parallelism: 1,
|
|
59
|
+
tagLength: 32,
|
|
60
|
+
});
|
|
61
|
+
// hash is a Uint8Array
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Without a bundler (Node, or browsers using `setupWasm` directly):
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import setupWasm from 'argon2id/lib/setup.js';
|
|
68
|
+
import { readFileSync } from 'fs';
|
|
69
|
+
|
|
70
|
+
const argon2id = await setupWasm(
|
|
71
|
+
importObj => WebAssembly.instantiate(readFileSync('node_modules/argon2id/dist/simd.wasm'), importObj),
|
|
72
|
+
importObj => WebAssembly.instantiate(readFileSync('node_modules/argon2id/dist/no-simd.wasm'), importObj),
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The hash function signature is the same either way.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Parameter presets
|
|
81
|
+
|
|
82
|
+
These align with OWASP and RFC 9106 recommendations:
|
|
83
|
+
|
|
84
|
+
| Name | `memorySize` | `passes` | `parallelism` | `tagLength` | Use case |
|
|
85
|
+
|------|-------------|---------|---------------|-------------|----------|
|
|
86
|
+
| INTERACTIVE | 19456 KiB | 2 | 1 | 32 | Login forms, session tokens |
|
|
87
|
+
| SENSITIVE | 65536 KiB | 3 | 4 | 32 | Master passwords, high-value secrets |
|
|
88
|
+
|
|
89
|
+
**INTERACTIVE** (~200–500 ms on modern hardware) is the right default for user
|
|
90
|
+
login. **SENSITIVE** (~1–2 s, 64 MiB) is for situations where latency is
|
|
91
|
+
acceptable in exchange for significantly stronger resistance: master passwords,
|
|
92
|
+
recovery keys, encryption keys derived from a passphrase.
|
|
93
|
+
|
|
94
|
+
A function that requires 64 MiB of RAM per evaluation means a GPU with 8 GiB
|
|
95
|
+
of VRAM can only run ~128 evaluations in parallel regardless of shader core
|
|
96
|
+
count. PBKDF2 at any practical iteration count cannot approach this resistance.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Password hashing and verification
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import loadArgon2idWasm from 'argon2id';
|
|
104
|
+
import { constantTimeEqual } from 'leviathan-crypto';
|
|
105
|
+
|
|
106
|
+
const argon2id = await loadArgon2idWasm();
|
|
107
|
+
|
|
108
|
+
// Registration — hash and store
|
|
109
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
110
|
+
const hash = argon2id({
|
|
111
|
+
password: new TextEncoder().encode(password),
|
|
112
|
+
salt,
|
|
113
|
+
passes: 2,
|
|
114
|
+
memorySize: 19456,
|
|
115
|
+
parallelism: 1,
|
|
116
|
+
tagLength: 32,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Store hash, salt, and params together. Salt is not secret.
|
|
120
|
+
db.store(userId, { hash, salt, passes: 2, memorySize: 19456, parallelism: 1 });
|
|
121
|
+
|
|
122
|
+
// Verification — recompute and compare
|
|
123
|
+
const stored = db.load(userId);
|
|
124
|
+
const candidate = argon2id({
|
|
125
|
+
password: new TextEncoder().encode(candidatePassword),
|
|
126
|
+
salt: stored.salt,
|
|
127
|
+
passes: stored.passes,
|
|
128
|
+
memorySize: stored.memorySize,
|
|
129
|
+
parallelism: stored.parallelism,
|
|
130
|
+
tagLength: 32,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// constantTimeEqual from leviathan-crypto prevents timing side-channels
|
|
134
|
+
const valid = constantTimeEqual(candidate, stored.hash);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
> [!IMPORTANT]
|
|
138
|
+
> The `salt` **must** be stored alongside the hash. It is not secret, but without
|
|
139
|
+
> the original salt the hash cannot be recomputed and verification will always
|
|
140
|
+
> fail. Store `salt`, `passes`, `memorySize`, and `parallelism` together as a
|
|
141
|
+
> unit.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Passphrase-based encryption with leviathan-crypto
|
|
146
|
+
|
|
147
|
+
Argon2id produces a root key from a passphrase. HKDF-SHA256 from leviathan then
|
|
148
|
+
expands that root key into the separate encryption and MAC keys that
|
|
149
|
+
`SerpentSeal` and `XChaCha20Poly1305` expect. Keeping the two steps separate
|
|
150
|
+
means the expensive Argon2id call happens once per passphrase, and HKDF handles
|
|
151
|
+
any further key material needed.
|
|
152
|
+
|
|
153
|
+
### With SerpentSeal
|
|
154
|
+
|
|
155
|
+
`SerpentSeal` takes a 64-byte key (32-byte enc key + 32-byte MAC key). HKDF
|
|
156
|
+
expands the 32-byte Argon2id output to 64 bytes:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import loadArgon2idWasm from 'argon2id';
|
|
160
|
+
import { init, SerpentSeal, HKDF_SHA256 } from 'leviathan-crypto';
|
|
161
|
+
|
|
162
|
+
await init(['serpent', 'sha2']);
|
|
163
|
+
const argon2id = await loadArgon2idWasm();
|
|
164
|
+
|
|
165
|
+
// ── Encrypt ──────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const argonSalt = crypto.getRandomValues(new Uint8Array(16));
|
|
168
|
+
const rootKey = argon2id({
|
|
169
|
+
password: new TextEncoder().encode(passphrase),
|
|
170
|
+
salt: argonSalt,
|
|
171
|
+
passes: 2,
|
|
172
|
+
memorySize: 19456,
|
|
173
|
+
parallelism: 1,
|
|
174
|
+
tagLength: 32,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const hkdf = new HKDF_SHA256();
|
|
178
|
+
const fullKey = hkdf.derive(rootKey, argonSalt, new TextEncoder().encode('serpent-seal-v1'), 64);
|
|
179
|
+
hkdf.dispose();
|
|
180
|
+
|
|
181
|
+
const serpent = new SerpentSeal();
|
|
182
|
+
const ciphertext = serpent.encrypt(fullKey, plaintext);
|
|
183
|
+
serpent.dispose();
|
|
184
|
+
|
|
185
|
+
// Store with ciphertext — all required for decryption
|
|
186
|
+
const envelope = { ciphertext, argonSalt };
|
|
187
|
+
|
|
188
|
+
// ── Decrypt ──────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const rootKey2 = argon2id({
|
|
191
|
+
password: new TextEncoder().encode(passphrase),
|
|
192
|
+
salt: envelope.argonSalt,
|
|
193
|
+
passes: 2,
|
|
194
|
+
memorySize: 19456,
|
|
195
|
+
parallelism: 1,
|
|
196
|
+
tagLength: 32,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const hkdf2 = new HKDF_SHA256();
|
|
200
|
+
const fullKey2 = hkdf2.derive(rootKey2, envelope.argonSalt, new TextEncoder().encode('serpent-seal-v1'), 64);
|
|
201
|
+
hkdf2.dispose();
|
|
202
|
+
|
|
203
|
+
const serpent2 = new SerpentSeal();
|
|
204
|
+
const decrypted = serpent2.decrypt(fullKey2, envelope.ciphertext);
|
|
205
|
+
serpent2.dispose();
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### With XChaCha20Poly1305
|
|
209
|
+
|
|
210
|
+
`XChaCha20Poly1305` takes a 32-byte key and a 24-byte nonce. The nonce is
|
|
211
|
+
generated fresh per encryption; only the Argon2id salt needs to be stored:
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import loadArgon2idWasm from 'argon2id';
|
|
215
|
+
import { init, XChaCha20Poly1305, HKDF_SHA256 } from 'leviathan-crypto';
|
|
216
|
+
|
|
217
|
+
await init(['chacha20', 'sha2']);
|
|
218
|
+
const argon2id = await loadArgon2idWasm();
|
|
219
|
+
|
|
220
|
+
// ── Encrypt ──────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
const argonSalt = crypto.getRandomValues(new Uint8Array(16));
|
|
223
|
+
const rootKey = argon2id({
|
|
224
|
+
password: new TextEncoder().encode(passphrase),
|
|
225
|
+
salt: argonSalt,
|
|
226
|
+
passes: 2,
|
|
227
|
+
memorySize: 19456,
|
|
228
|
+
parallelism: 1,
|
|
229
|
+
tagLength: 32,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// tagLength: 32 already matches XChaCha20Poly1305's expected key size
|
|
233
|
+
// HKDF is optional here but included for domain separation.
|
|
234
|
+
const hkdf = new HKDF_SHA256();
|
|
235
|
+
const key = hkdf.derive(rootKey, argonSalt, new TextEncoder().encode('xchacha-v1'), 32);
|
|
236
|
+
hkdf.dispose();
|
|
237
|
+
|
|
238
|
+
const nonce = crypto.getRandomValues(new Uint8Array(24));
|
|
239
|
+
const xc = new XChaCha20Poly1305();
|
|
240
|
+
const ct = xc.encrypt(key, nonce, plaintext);
|
|
241
|
+
xc.dispose();
|
|
242
|
+
|
|
243
|
+
const envelope = { ct, nonce, argonSalt };
|
|
244
|
+
|
|
245
|
+
// ── Decrypt ──────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const rootKey2 = argon2id({
|
|
248
|
+
password: new TextEncoder().encode(passphrase),
|
|
249
|
+
salt: envelope.argonSalt,
|
|
250
|
+
passes: 2,
|
|
251
|
+
memorySize: 19456,
|
|
252
|
+
parallelism: 1,
|
|
253
|
+
tagLength: 32,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const hkdf2 = new HKDF_SHA256();
|
|
257
|
+
const key2 = hkdf2.derive(rootKey2, envelope.argonSalt, new TextEncoder().encode('xchacha-v1'), 32);
|
|
258
|
+
hkdf2.dispose();
|
|
259
|
+
|
|
260
|
+
const xc2 = new XChaCha20Poly1305();
|
|
261
|
+
const decrypted = xc2.decrypt(key2, envelope.nonce, envelope.ct);
|
|
262
|
+
xc2.dispose();
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
> [!CAUTION]
|
|
266
|
+
> Never reuse an Argon2id salt across different passphrases or key derivation
|
|
267
|
+
> contexts. Generate a fresh random salt for each new encryption envelope. The
|
|
268
|
+
> salt is not secret — store it in plaintext alongside the ciphertext.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Memory note
|
|
273
|
+
|
|
274
|
+
>[!IMPORTANT]
|
|
275
|
+
> Each call to `loadArgon2idWasm()` instantiates a separate WASM instance. The
|
|
276
|
+
> package's own documentation recommends reloading the module between hashes when
|
|
277
|
+
> the `memorySize` varies significantly, since WASM linear memory is not
|
|
278
|
+
> deallocated between calls. For a single `memorySize` used consistently (the
|
|
279
|
+
> common case), one `await loadArgon2idWasm()` call at startup is correct.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Cross-References
|
|
284
|
+
|
|
285
|
+
- [README.md](./README.md) — project overview and quick-start guide
|
|
286
|
+
- [sha2.md](./sha2.md) — HKDF-SHA256 for key expansion from Argon2id root keys
|
|
287
|
+
- [serpent.md](./serpent.md) — SerpentSeal: Serpent-256 authenticated encryption (pairs with Argon2id-derived keys)
|
|
288
|
+
- [chacha20.md](./chacha20.md) — XChaCha20Poly1305: ChaCha20 authenticated encryption (pairs with Argon2id-derived keys)
|
|
289
|
+
- [utils.md](./utils.md) — `randomBytes` for generating salts, `constantTimeEqual` for hash verification
|
|
290
|
+
- [architecture.md](./architecture.md) — library architecture and design decisions
|