leviathan-crypto 2.0.0 → 2.1.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.
Files changed (98) hide show
  1. package/CLAUDE.md +171 -7
  2. package/LICENSE +4 -0
  3. package/README.md +109 -54
  4. package/SECURITY.md +125 -233
  5. package/dist/chacha20/cipher-suite.d.ts +10 -0
  6. package/dist/chacha20/cipher-suite.js +66 -2
  7. package/dist/chacha20/generator.d.ts +12 -0
  8. package/dist/chacha20/generator.js +91 -0
  9. package/dist/chacha20/index.d.ts +97 -1
  10. package/dist/chacha20/index.js +139 -11
  11. package/dist/chacha20/ops.d.ts +57 -6
  12. package/dist/chacha20/ops.js +93 -13
  13. package/dist/chacha20/pool-worker.js +12 -0
  14. package/dist/chacha20/types.d.ts +1 -32
  15. package/dist/ct-wasm.js +1 -1
  16. package/dist/ct.wasm +0 -0
  17. package/dist/docs/aead.md +69 -26
  18. package/dist/docs/architecture.md +600 -520
  19. package/dist/docs/argon2id.md +17 -14
  20. package/dist/docs/chacha20.md +146 -39
  21. package/dist/docs/exports.md +46 -10
  22. package/dist/docs/fortuna.md +339 -122
  23. package/dist/docs/init.md +24 -25
  24. package/dist/docs/loader.md +142 -47
  25. package/dist/docs/serpent.md +139 -41
  26. package/dist/docs/sha2.md +77 -19
  27. package/dist/docs/sha3.md +81 -15
  28. package/dist/docs/types.md +156 -15
  29. package/dist/docs/utils.md +171 -81
  30. package/dist/embedded/chacha20-pool-worker.d.ts +1 -0
  31. package/dist/embedded/chacha20-pool-worker.js +5 -0
  32. package/dist/embedded/kyber.d.ts +1 -1
  33. package/dist/embedded/kyber.js +1 -1
  34. package/dist/embedded/serpent-pool-worker.d.ts +1 -0
  35. package/dist/embedded/serpent-pool-worker.js +5 -0
  36. package/dist/embedded/serpent.d.ts +1 -1
  37. package/dist/embedded/serpent.js +1 -1
  38. package/dist/fortuna.d.ts +14 -8
  39. package/dist/fortuna.js +144 -50
  40. package/dist/index.d.ts +8 -6
  41. package/dist/index.js +6 -5
  42. package/dist/init.d.ts +0 -2
  43. package/dist/init.js +83 -3
  44. package/dist/kyber/indcpa.js +4 -4
  45. package/dist/kyber/index.js +25 -5
  46. package/dist/kyber/kem.js +56 -1
  47. package/dist/kyber/suite.d.ts +1 -2
  48. package/dist/kyber/suite.js +1 -0
  49. package/dist/kyber/types.d.ts +1 -0
  50. package/dist/kyber/validate.d.ts +8 -4
  51. package/dist/kyber/validate.js +18 -14
  52. package/dist/kyber.wasm +0 -0
  53. package/dist/loader.d.ts +7 -2
  54. package/dist/loader.js +25 -28
  55. package/dist/ratchet/index.d.ts +6 -0
  56. package/dist/ratchet/index.js +37 -0
  57. package/dist/ratchet/kdf-chain.d.ts +13 -0
  58. package/dist/ratchet/kdf-chain.js +85 -0
  59. package/dist/ratchet/ratchet-keypair.d.ts +9 -0
  60. package/dist/ratchet/ratchet-keypair.js +61 -0
  61. package/dist/ratchet/root-kdf.d.ts +4 -0
  62. package/dist/ratchet/root-kdf.js +124 -0
  63. package/dist/ratchet/skipped-key-store.d.ts +14 -0
  64. package/dist/ratchet/skipped-key-store.js +154 -0
  65. package/dist/ratchet/types.d.ts +36 -0
  66. package/dist/ratchet/types.js +26 -0
  67. package/dist/serpent/cipher-suite.d.ts +10 -0
  68. package/dist/serpent/cipher-suite.js +136 -50
  69. package/dist/serpent/generator.d.ts +12 -0
  70. package/dist/serpent/generator.js +97 -0
  71. package/dist/serpent/index.d.ts +61 -1
  72. package/dist/serpent/index.js +92 -7
  73. package/dist/serpent/pool-worker.js +25 -95
  74. package/dist/serpent/serpent-cbc.d.ts +14 -4
  75. package/dist/serpent/serpent-cbc.js +58 -34
  76. package/dist/serpent/shared-ops.d.ts +83 -0
  77. package/dist/serpent/shared-ops.js +213 -0
  78. package/dist/serpent/types.d.ts +1 -5
  79. package/dist/serpent.wasm +0 -0
  80. package/dist/sha2/hash.d.ts +2 -0
  81. package/dist/sha2/hash.js +53 -0
  82. package/dist/sha2/index.d.ts +1 -0
  83. package/dist/sha2/index.js +15 -1
  84. package/dist/sha3/hash.d.ts +2 -0
  85. package/dist/sha3/hash.js +53 -0
  86. package/dist/sha3/index.d.ts +17 -2
  87. package/dist/sha3/index.js +79 -7
  88. package/dist/stream/header.js +5 -5
  89. package/dist/stream/open-stream.js +36 -14
  90. package/dist/stream/seal-stream-pool.d.ts +1 -0
  91. package/dist/stream/seal-stream-pool.js +47 -8
  92. package/dist/stream/seal-stream.js +29 -11
  93. package/dist/stream/types.d.ts +1 -0
  94. package/dist/types.d.ts +21 -0
  95. package/dist/utils.d.ts +7 -8
  96. package/dist/utils.js +73 -40
  97. package/dist/wasm-source.d.ts +9 -8
  98. package/package.json +79 -64
package/dist/fortuna.js CHANGED
@@ -22,18 +22,16 @@
22
22
  // src/ts/fortuna.ts
23
23
  //
24
24
  // Fortuna CSPRNG — Ferguson & Schneier, Practical Cryptography (2003), Chapter 9.
25
- // Backed by WASM Serpent-256 ECB (generator) and WASM SHA-256 (accumulator pools).
26
- // Requires init({ serpent: ..., sha2: ... }) before Fortuna.create().
27
- import { isInitialized } from './init.js';
28
- import { Serpent } from './serpent/index.js';
29
- import { SHA256 } from './sha2/index.js';
25
+ // Backed by a pluggable Generator (cipher PRF) and HashFn (accumulator hash).
26
+ // Requires init() for the modules used by the chosen generator and hash pair.
27
+ import { isInitialized, getInstance } from './init.js';
30
28
  import { wipe, utf8ToBytes, concat } from './utils.js';
31
29
  const isBrowser = typeof window !== 'undefined';
32
30
  const isNode = typeof process !== 'undefined' && typeof process.pid === 'number';
33
31
  /**
34
32
  * Fortuna CSPRNG — spec §9.3–§9.5
35
33
  *
36
- * Use `Fortuna.create()` to instantiate. Direct construction is not allowed.
34
+ * Use `Fortuna.create({ generator, hash })` to instantiate. Direct construction is not allowed.
37
35
  */
38
36
  export class Fortuna {
39
37
  // ── Constants ──────────────────────────────────────────────────────────
@@ -43,9 +41,9 @@ export class Fortuna {
43
41
  static NODE_STATS_INTERVAL = 1000; // ms — OS stats collector interval
44
42
  static CRYPTO_INTERVAL = 3000; // ms — crypto.randomBytes interval
45
43
  // ── State ─────────────────────────────────────────────────────────────
46
- serpent;
47
- sha;
48
- poolHash; // 32 running SHA-256 chain hashes (32 bytes each)
44
+ gen;
45
+ hash;
46
+ poolHash; // 32 running hash chain values
49
47
  poolEntropy;
50
48
  genKey;
51
49
  genCnt;
@@ -62,25 +60,33 @@ export class Fortuna {
62
60
  timers = [];
63
61
  // ── Static factory ────────────────────────────────────────────────────
64
62
  static async create(opts) {
65
- if (!isInitialized('serpent'))
66
- throw new Error('leviathan-crypto: call init({ serpent: ..., sha2: ... }) before using Fortuna');
67
- if (!isInitialized('sha2'))
68
- throw new Error('leviathan-crypto: call init({ serpent: ..., sha2: ... }) before using Fortuna');
69
- const f = new Fortuna(opts?.msPerReseed ?? Fortuna.MS_PER_RESEED);
70
- f.initialize(opts?.entropy);
63
+ if (!opts || !opts.generator || !opts.hash)
64
+ throw new TypeError('leviathan-crypto: Fortuna.create() requires { generator, hash }');
65
+ if (opts.hash.outputSize !== opts.generator.keySize)
66
+ throw new RangeError(`leviathan-crypto: Fortuna requires hash.outputSize (${opts.hash.outputSize}) `
67
+ + `to match generator.keySize (${opts.generator.keySize})`);
68
+ const required = new Set([...opts.generator.wasmModules, ...opts.hash.wasmModules]);
69
+ for (const mod of required) {
70
+ if (!isInitialized(mod)) {
71
+ const args = [...required].map(m => `${m}: ...`).join(', ');
72
+ throw new Error(`leviathan-crypto: call init({ ${args} }) before using Fortuna`);
73
+ }
74
+ }
75
+ const f = new Fortuna(opts.generator, opts.hash, opts.msPerReseed ?? Fortuna.MS_PER_RESEED);
76
+ f.initialize(opts.entropy);
71
77
  // Force the first reseed — pool[0] is saturated by initialize(),
72
78
  // so this call triggers an immediate reseed and guarantees get() never
73
79
  // returns undefined. The byte is discarded.
74
80
  f.get(1);
75
81
  return f;
76
82
  }
77
- constructor(msPerReseed) {
78
- this.serpent = new Serpent();
79
- this.sha = new SHA256();
83
+ constructor(gen, hash, msPerReseed) {
84
+ this.gen = gen;
85
+ this.hash = hash;
80
86
  this.poolHash = [];
81
87
  this.poolEntropy = [];
82
- this.genKey = new Uint8Array(32);
83
- this.genCnt = new Uint8Array(16);
88
+ this.genKey = new Uint8Array(gen.keySize);
89
+ this.genCnt = new Uint8Array(gen.counterSize);
84
90
  this.reseedCnt = 0;
85
91
  this.lastReseed = 0;
86
92
  this.entropyLevel = 0;
@@ -90,7 +96,7 @@ export class Fortuna {
90
96
  this.msPerReseed = msPerReseed;
91
97
  this.robin = { kbd: 0, mouse: 0, scroll: 0, touch: 0, motion: 0, time: 0, rnd: 0, dom: 0 };
92
98
  for (let i = 0; i < Fortuna.NUM_POOLS; i++) {
93
- this.poolHash.push(new Uint8Array(32)); // zero-initialized chain value
99
+ this.poolHash.push(new Uint8Array(hash.outputSize)); // zero-initialized chain value
94
100
  this.poolEntropy.push(0);
95
101
  }
96
102
  }
@@ -109,17 +115,23 @@ export class Fortuna {
109
115
  let seed = new Uint8Array(0);
110
116
  let strength = 0;
111
117
  for (let i = 0; i < Fortuna.NUM_POOLS; i++) {
112
- if ((this.reseedCnt & (1 << i)) !== 0) {
118
+ // Practical Cryptography (Ferguson & Schneier, 2003) §9.5.5:
119
+ // pool P_i is used in reseed r iff 2^i divides r.
120
+ if ((this.reseedCnt & ((1 << i) - 1)) === 0) {
113
121
  // Pool digest = current chain hash
114
122
  seed = concat(seed, this.poolHash[i]);
115
123
  strength += this.poolEntropy[i];
116
- // Reset pool
117
- this.poolHash[i] = new Uint8Array(32);
124
+ // Reset pool — wipe old chain hash before dropping the reference.
125
+ const old = this.poolHash[i];
126
+ this.poolHash[i] = new Uint8Array(this.hash.outputSize);
127
+ wipe(old);
118
128
  this.poolEntropy[i] = 0;
119
129
  }
120
130
  }
121
131
  this.entropyLevel -= strength;
122
132
  this.reseed(seed);
133
+ // seed is built from concatenated pool-hash copies; wipe the temp.
134
+ wipe(seed);
123
135
  }
124
136
  return this.pseudoRandomData(length);
125
137
  }
@@ -140,11 +152,33 @@ export class Fortuna {
140
152
  stop() {
141
153
  if (this.disposed)
142
154
  throw new Error('Fortuna instance has been disposed');
155
+ // Mark disposed FIRST. WASM wipeBuffers can throw if a stateful instance
156
+ // holds the module; we must not allow get()/addEntropy()/getEntropy() to
157
+ // run on a partially-disposed instance.
158
+ this.disposed = true;
143
159
  this.stopCollectors();
144
160
  wipe(this.genKey);
145
161
  wipe(this.genCnt);
162
+ // Wipe all 32 pool-hash chain values so residual entropy-bearing
163
+ // bytes do not outlive the instance.
164
+ for (const p of this.poolHash)
165
+ wipe(p);
146
166
  this.reseedCnt = 0;
147
- this.disposed = true;
167
+ // Best-effort wipe of WASM scratch buffers for every module the chosen
168
+ // generator and hash touched. Surface the first error so the caller
169
+ // knows the WASM scratch leak occurred.
170
+ const required = new Set([...this.gen.wasmModules, ...this.hash.wasmModules]);
171
+ let err;
172
+ for (const mod of required) {
173
+ try {
174
+ getInstance(mod).exports.wipeBuffers();
175
+ }
176
+ catch (e) {
177
+ err ??= e;
178
+ }
179
+ }
180
+ if (err)
181
+ throw err;
148
182
  }
149
183
  // ── Test-only accessors ───────────────────────────────────────────────
150
184
  /** @internal — exposed for testing key replacement */
@@ -159,45 +193,93 @@ export class Fortuna {
159
193
  _getReseedCnt() {
160
194
  return this.reseedCnt;
161
195
  }
162
- // ── Generator (spec §9.4) ─────────────────────────────────────────────
163
- /** Generate n blocks of 16 bytes each. — spec §9.4 */
164
- generateBlocks(n) {
165
- const out = new Uint8Array(n * 16);
166
- for (let i = 0; i < n; i++) {
167
- // Encrypt genCnt with Serpent-256 ECB
168
- this.serpent.loadKey(this.genKey);
169
- out.set(this.serpent.encryptBlock(this.genCnt), i * 16);
170
- this.incrementCounter();
196
+ /** @internal exposed for testing pool-hash backing arrays */
197
+ _getPoolHash() {
198
+ return this.poolHash;
199
+ }
200
+ /**
201
+ * @internal test-only deterministic factory. Seeds pool[0] with the provided
202
+ * entropy and triggers one reseed directly, bypassing all OS entropy collection
203
+ * and the hrtime jitter capture in get(). This makes KAT vectors reproducible
204
+ * across runs. Not suitable for production use.
205
+ */
206
+ static async _createDeterministicForTesting(opts) {
207
+ if (!opts || !opts.generator || !opts.hash)
208
+ throw new TypeError('Fortuna._createDeterministicForTesting() requires { generator, hash, entropy }');
209
+ if (opts.hash.outputSize !== opts.generator.keySize)
210
+ throw new RangeError(`leviathan-crypto: Fortuna requires hash.outputSize (${opts.hash.outputSize}) `
211
+ + `to match generator.keySize (${opts.generator.keySize})`);
212
+ const required = new Set([...opts.generator.wasmModules, ...opts.hash.wasmModules]);
213
+ for (const mod of required) {
214
+ if (!isInitialized(mod)) {
215
+ const args = [...required].map(m => `${m}: ...`).join(', ');
216
+ throw new Error(`leviathan-crypto: call init({ ${args} }) before using Fortuna`);
217
+ }
171
218
  }
172
- return out;
219
+ const f = new Fortuna(opts.generator, opts.hash, 0);
220
+ // Seed pool[0] with the provided entropy, no OS collection.
221
+ f.addRandomEvent(opts.entropy, 0, opts.entropy.length * 8);
222
+ // Manually trigger reseed #1 without calling get() — get() calls captureHrtime()
223
+ // in Node.js which adds non-deterministic data before the reseed fires.
224
+ f.reseedFromPool0();
225
+ return f;
173
226
  }
227
+ // ── Generator (spec §9.4) ─────────────────────────────────────────────
174
228
  /** Get length pseudo-random bytes. — spec §9.4 */
175
229
  pseudoRandomData(length) {
176
- // Generate ceil(length/16) blocks — spec §9.4
177
- const blocks = Math.ceil(length / 16);
178
- const raw = this.generateBlocks(blocks);
179
- const output = raw.slice(0, length);
180
- // Key replacement — mandatory forward secrecy (spec §9.4)
181
- this.genKey = this.generateBlocks(2);
182
- return output;
230
+ const blocks = Math.ceil(length / this.gen.blockSize);
231
+ const out = this.gen.generate(this.genKey, this.genCnt, length);
232
+ // External counter advance — generator is stateless and does not mutate caller's counter
233
+ for (let i = 0; i < blocks; i++)
234
+ this.incrementCounter();
235
+ // Key replacement — mandatory forward secrecy (spec §9.4).
236
+ // Wipe the prior key BEFORE dropping its reference so no key bytes are
237
+ // reachable after key replacement; anyone holding a Uint8Array view to
238
+ // the old key now observes zero.
239
+ const newKey = this.gen.generate(this.genKey, this.genCnt, this.gen.keySize);
240
+ for (let i = 0; i < Math.ceil(this.gen.keySize / this.gen.blockSize); i++)
241
+ this.incrementCounter();
242
+ wipe(this.genKey);
243
+ this.genKey = newKey;
244
+ return out;
183
245
  }
184
246
  /** Reseed the generator — spec §9.4 */
185
247
  reseed(seed) {
186
- // genKey = SHA256(genKey ‖ seed)
187
- this.genKey = this.sha.hash(concat(this.genKey, seed));
248
+ // genKey = hash(genKey ‖ seed). Wipe both the hash input and the
249
+ // prior key before dropping references.
250
+ const combined = concat(this.genKey, seed);
251
+ const newKey = this.hash.digest(combined);
252
+ wipe(combined);
253
+ wipe(this.genKey);
254
+ this.genKey = newKey;
188
255
  // Increment counter — makes it nonzero on first reseed, marking generator as seeded
189
256
  this.incrementCounter();
190
257
  this.lastReseed = Date.now();
191
258
  }
192
- /** Increment 16-byte little-endian counter. spec §9.4 */
259
+ /** Drain pool 0 into a fresh seed and reseed. Used by the deterministic
260
+ * test factory; production reseeds in get() walk the §9.5.5 schedule
261
+ * across all pools, not just pool 0. Caller is responsible for any
262
+ * entropy-threshold check. */
263
+ reseedFromPool0() {
264
+ this.reseedCnt = (this.reseedCnt + 1) >>> 0;
265
+ const seed = this.poolHash[0].slice();
266
+ const old = this.poolHash[0];
267
+ this.poolHash[0] = new Uint8Array(this.hash.outputSize);
268
+ wipe(old);
269
+ this.entropyLevel -= this.poolEntropy[0];
270
+ this.poolEntropy[0] = 0;
271
+ this.reseed(seed);
272
+ wipe(seed);
273
+ }
274
+ /** Increment little-endian counter. — spec §9.4 */
193
275
  incrementCounter() {
194
- for (let i = 0; i < 16; i++) {
276
+ for (let i = 0; i < this.genCnt.length; i++) {
195
277
  if (++this.genCnt[i] !== 0)
196
278
  break;
197
279
  }
198
280
  }
199
281
  // ── Accumulator (spec §9.5) ───────────────────────────────────────────
200
- /** Add an event to a pool via hash chaining: poolHash[i] = SHA256(poolHash[i] ‖ eventId ‖ data). */
282
+ /** Add an event to a pool via hash chaining: poolHash[i] = hash(poolHash[i] ‖ eventId ‖ data). */
201
283
  addRandomEvent(data, poolIdx, entropyBits) {
202
284
  // Encode eventId as 4 bytes little-endian
203
285
  const id = new Uint8Array(4);
@@ -206,8 +288,13 @@ export class Fortuna {
206
288
  id[2] = (this.eventId >>> 16) & 0xff;
207
289
  id[3] = (this.eventId >>> 24) & 0xff;
208
290
  this.eventId = (this.eventId + 1) >>> 0; // u32 wrap
209
- // Chain: poolHash[i] = SHA256(poolHash[i] ‖ id ‖ data)
210
- this.poolHash[poolIdx] = this.sha.hash(concat(this.poolHash[poolIdx], id, data));
291
+ // Chain: poolHash[i] = hash(poolHash[i] ‖ id ‖ data).
292
+ // Wipe the chain input and the prior chain value before dropping refs.
293
+ const combined = concat(this.poolHash[poolIdx], id, data);
294
+ const newChain = this.hash.digest(combined);
295
+ wipe(combined);
296
+ wipe(this.poolHash[poolIdx]);
297
+ this.poolHash[poolIdx] = newChain;
211
298
  this.poolEntropy[poolIdx] += entropyBits;
212
299
  this.entropyLevel += entropyBits;
213
300
  }
@@ -226,6 +313,13 @@ export class Fortuna {
226
313
  this.addRandomEvent(entropy, this.robin.rnd, entropy.length * 8);
227
314
  this.robin.rnd = (this.robin.rnd + 1) % Fortuna.NUM_POOLS;
228
315
  }
316
+ // F-2 invariant: fail loudly if no OS entropy source delivered anything.
317
+ // The try/catch in collectorCryptoRandom is preserved to protect against
318
+ // platforms where crypto.getRandomValues itself throws (non-standard
319
+ // runtimes). This post-init check covers all silent-failure paths uniformly.
320
+ if (this.poolEntropy[0] < Fortuna.RESEED_LIMIT)
321
+ throw new Error('leviathan-crypto: Fortuna initialization could not gather sufficient entropy. '
322
+ + 'No working crypto.getRandomValues or node:crypto in this environment.');
229
323
  this.startCollectors();
230
324
  }
231
325
  // ── Collectors ────────────────────────────────────────────────────────
@@ -358,7 +452,7 @@ export class Fortuna {
358
452
  }
359
453
  collectorDom() {
360
454
  if (typeof document !== 'undefined' && document.documentElement) {
361
- this.addRandomEvent(this.sha.hash(utf8ToBytes(document.documentElement.innerHTML)), this.robin.dom, 2);
455
+ this.addRandomEvent(this.hash.digest(utf8ToBytes(document.documentElement.innerHTML)), this.robin.dom, 2);
362
456
  this.robin.dom = (this.robin.dom + 1) % Fortuna.NUM_POOLS;
363
457
  }
364
458
  }
package/dist/index.d.ts CHANGED
@@ -13,17 +13,19 @@ import type { WasmSource } from './wasm-source.js';
13
13
  */
14
14
  export declare function init(sources: Partial<Record<Module, WasmSource>>): Promise<void>;
15
15
  export type { Module, WasmSource };
16
- export { isInitialized, _resetForTesting } from './init.js';
16
+ export { isInitialized } from './init.js';
17
17
  export { AuthenticationError } from './errors.js';
18
- export { serpentInit, Serpent, SerpentCtr, SerpentCbc, SerpentCipher, _serpentReady } from './serpent/index.js';
19
- export { chacha20Init, ChaCha20, Poly1305, ChaCha20Poly1305, XChaCha20Poly1305, XChaCha20Cipher, _chachaReady } from './chacha20/index.js';
20
- export { sha2Init, SHA256, SHA512, SHA384, HMAC_SHA256, HMAC_SHA512, HMAC_SHA384, HKDF_SHA256, HKDF_SHA512, _sha2Ready } from './sha2/index.js';
21
- export { sha3Init, SHA3_224, SHA3_256, SHA3_384, SHA3_512, SHAKE128, SHAKE256, _sha3Ready } from './sha3/index.js';
18
+ export { serpentInit, Serpent, SerpentCtr, SerpentCbc, SerpentCipher, SerpentGenerator, _serpentReady } from './serpent/index.js';
19
+ export { chacha20Init, ChaCha20, Poly1305, ChaCha20Poly1305, XChaCha20Poly1305, XChaCha20Cipher, ChaCha20Generator, _chachaReady } from './chacha20/index.js';
20
+ export { sha2Init, SHA256, SHA512, SHA384, HMAC_SHA256, HMAC_SHA512, HMAC_SHA384, HKDF_SHA256, HKDF_SHA512, SHA256Hash, _sha2Ready } from './sha2/index.js';
21
+ export { sha3Init, SHA3_224, SHA3_256, SHA3_384, SHA3_512, SHAKE128, SHAKE256, SHA3_256Hash, _sha3Ready } from './sha3/index.js';
22
22
  export { keccakInit } from './keccak/index.js';
23
23
  export { kyberInit, MlKem512, MlKem768, MlKem1024, MlKemBase, KyberSuite, _kyberReady } from './kyber/index.js';
24
24
  export type { KyberKeyPair, KyberEncapsulation, KyberParams } from './kyber/index.js';
25
25
  export { SealStream, OpenStream, Seal, SealStreamPool, FLAG_FRAMED, TAG_DATA, TAG_FINAL, HEADER_SIZE, CHUNK_MIN, CHUNK_MAX } from './stream/index.js';
26
26
  export type { CipherSuite, DerivedKeys, SealStreamOpts, PoolOpts } from './stream/index.js';
27
27
  export { Fortuna } from './fortuna.js';
28
- export type { Hash, KeyedHash, Blockcipher, Streamcipher, AEAD } from './types.js';
28
+ export type { Hash, KeyedHash, Blockcipher, Streamcipher, AEAD, Generator, HashFn } from './types.js';
29
+ export { KDFChain, ratchetInit, kemRatchetEncap, kemRatchetDecap, ratchetReady, SkippedKeyStore, RatchetKeypair, } from './ratchet/index.js';
30
+ export type { RatchetInitResult, KemEncapResult, KemDecapResult, MlKemLike, RatchetMessageHeader, ResolveHandle, SkippedKeyStoreOpts, } from './ratchet/index.js';
29
31
  export { hexToBytes, bytesToHex, utf8ToBytes, bytesToUtf8, base64ToBytes, bytesToBase64, constantTimeEqual, CT_MAX_BYTES, wipe, xor, concat, randomBytes, hasSIMD, } from './utils.js';
package/dist/index.js CHANGED
@@ -60,14 +60,15 @@ export async function init(sources) {
60
60
  }
61
61
  await Promise.all(entries.map(([mod, src]) => _dispatchers[mod](src)));
62
62
  }
63
- export { isInitialized, _resetForTesting } from './init.js';
63
+ export { isInitialized } from './init.js';
64
64
  export { AuthenticationError } from './errors.js';
65
- export { serpentInit, Serpent, SerpentCtr, SerpentCbc, SerpentCipher, _serpentReady } from './serpent/index.js';
66
- export { chacha20Init, ChaCha20, Poly1305, ChaCha20Poly1305, XChaCha20Poly1305, XChaCha20Cipher, _chachaReady } from './chacha20/index.js';
67
- export { sha2Init, SHA256, SHA512, SHA384, HMAC_SHA256, HMAC_SHA512, HMAC_SHA384, HKDF_SHA256, HKDF_SHA512, _sha2Ready } from './sha2/index.js';
68
- export { sha3Init, SHA3_224, SHA3_256, SHA3_384, SHA3_512, SHAKE128, SHAKE256, _sha3Ready } from './sha3/index.js';
65
+ export { serpentInit, Serpent, SerpentCtr, SerpentCbc, SerpentCipher, SerpentGenerator, _serpentReady } from './serpent/index.js';
66
+ export { chacha20Init, ChaCha20, Poly1305, ChaCha20Poly1305, XChaCha20Poly1305, XChaCha20Cipher, ChaCha20Generator, _chachaReady } from './chacha20/index.js';
67
+ export { sha2Init, SHA256, SHA512, SHA384, HMAC_SHA256, HMAC_SHA512, HMAC_SHA384, HKDF_SHA256, HKDF_SHA512, SHA256Hash, _sha2Ready } from './sha2/index.js';
68
+ export { sha3Init, SHA3_224, SHA3_256, SHA3_384, SHA3_512, SHAKE128, SHAKE256, SHA3_256Hash, _sha3Ready } from './sha3/index.js';
69
69
  export { keccakInit } from './keccak/index.js';
70
70
  export { kyberInit, MlKem512, MlKem768, MlKem1024, MlKemBase, KyberSuite, _kyberReady } from './kyber/index.js';
71
71
  export { SealStream, OpenStream, Seal, SealStreamPool, FLAG_FRAMED, TAG_DATA, TAG_FINAL, HEADER_SIZE, CHUNK_MIN, CHUNK_MAX } from './stream/index.js';
72
72
  export { Fortuna } from './fortuna.js';
73
+ export { KDFChain, ratchetInit, kemRatchetEncap, kemRatchetDecap, ratchetReady, SkippedKeyStore, RatchetKeypair, } from './ratchet/index.js';
73
74
  export { hexToBytes, bytesToHex, utf8ToBytes, bytesToUtf8, base64ToBytes, bytesToBase64, constantTimeEqual, CT_MAX_BYTES, wipe, xor, concat, randomBytes, hasSIMD, } from './utils.js';
package/dist/init.d.ts CHANGED
@@ -3,5 +3,3 @@ export type Module = 'serpent' | 'chacha20' | 'sha2' | 'sha3' | 'keccak' | 'kybe
3
3
  export declare function initModule(mod: Module, source: WasmSource): Promise<void>;
4
4
  export declare function getInstance(mod: Module): WebAssembly.Instance;
5
5
  export declare function isInitialized(mod: Module): boolean;
6
- /** Reset all cached instances — for testing only */
7
- export declare function _resetForTesting(): void;
package/dist/init.js CHANGED
@@ -7,26 +7,106 @@ function resolve(mod) {
7
7
  }
8
8
  // Module-scope cache: one WebAssembly.Instance per canonical module
9
9
  const instances = new Map();
10
+ // Pending inits — coalesces concurrent initModule calls for the same module.
11
+ const pending = new Map();
12
+ // Exclusivity registry: per-module ownership token held by a stateful wrapper
13
+ // for its entire lifetime. Prevents shared-WASM-state clobber when two
14
+ // instances from the same module would otherwise trample each other's memory.
15
+ const owners = new Map();
10
16
  export async function initModule(mod, source) {
11
17
  const resolved = resolve(mod);
12
18
  if (instances.has(resolved))
13
19
  return;
20
+ const inflight = pending.get(resolved);
21
+ if (inflight) {
22
+ await inflight;
23
+ return;
24
+ }
14
25
  if ((resolved === 'serpent' || resolved === 'chacha20' || resolved === 'kyber') && !hasSIMD())
15
26
  throw new Error('leviathan-crypto: serpent, chacha20, and kyber require WebAssembly SIMD — '
16
27
  + 'this runtime does not support it');
17
- instances.set(resolved, await loadWasm(source));
28
+ const p = loadWasm(source);
29
+ pending.set(resolved, p);
30
+ try {
31
+ instances.set(resolved, await p);
32
+ }
33
+ finally {
34
+ pending.delete(resolved);
35
+ }
18
36
  }
19
37
  export function getInstance(mod) {
20
- const inst = instances.get(resolve(mod));
38
+ const r = resolve(mod);
39
+ const inst = instances.get(r);
21
40
  if (!inst) {
22
41
  throw new Error(`leviathan-crypto: call init({ ${mod}: ... }) before using this class`);
23
42
  }
43
+ if (owners.has(r)) {
44
+ throw new Error(`leviathan-crypto: another stateful instance is using the '${r}' WASM module — `
45
+ + 'call dispose() on it before constructing a new one');
46
+ }
24
47
  return inst;
25
48
  }
26
49
  export function isInitialized(mod) {
27
50
  return instances.has(resolve(mod));
28
51
  }
29
- /** Reset all cached instances — for testing only */
52
+ /**
53
+ * Acquire exclusive access to `mod`. Throws if another stateful instance
54
+ * currently holds it. Returned token must be passed to `_releaseModule`.
55
+ * @internal
56
+ */
57
+ export function _acquireModule(mod) {
58
+ const r = resolve(mod);
59
+ if (owners.has(r))
60
+ throw new Error(`leviathan-crypto: another stateful instance is using the '${r}' WASM module — `
61
+ + 'call dispose() on it before constructing a new one');
62
+ const tok = Symbol(r);
63
+ owners.set(r, tok);
64
+ return tok;
65
+ }
66
+ /**
67
+ * Release exclusive access. No-op if the token doesn't match the current
68
+ * owner (makes dispose idempotent).
69
+ * @internal
70
+ */
71
+ export function _releaseModule(mod, tok) {
72
+ const r = resolve(mod);
73
+ if (owners.get(r) === tok)
74
+ owners.delete(r);
75
+ }
76
+ /**
77
+ * True if a stateful instance currently holds the module.
78
+ * @internal
79
+ */
80
+ export function _isModuleBusy(mod) {
81
+ return owners.has(resolve(mod));
82
+ }
83
+ /**
84
+ * Throw if `mod` is currently held by a stateful instance. Called at the top
85
+ * of every atomic WASM-touching method so that cached-exports access paths
86
+ * cannot silently clobber a live stateful instance's WASM state.
87
+ *
88
+ * The error message is intentionally identical to `_acquireModule`'s so that
89
+ * error handlers matching on text work uniformly across construction-time and
90
+ * method-time ownership failures.
91
+ * @internal
92
+ */
93
+ export function _assertNotOwned(mod) {
94
+ // Deliberately unoptimized. Do not add caching or epoch tracking: the
95
+ // check must read current ownership on every call so an atomic op cannot
96
+ // race ahead of a stateful acquirer.
97
+ const r = resolve(mod);
98
+ if (owners.has(r))
99
+ throw new Error(`leviathan-crypto: another stateful instance is using the '${r}' WASM module — `
100
+ + 'call dispose() on it before constructing a new one');
101
+ }
102
+ /**
103
+ * Reset all cached instances — for testing only. Clears `instances`, `pending`,
104
+ * and `owners` so tests can re-exercise module lifecycle (init, exclusivity,
105
+ * race) from a known-empty state.
106
+ * @internal
107
+ */
30
108
  export function _resetForTesting() {
31
109
  instances.clear();
110
+ pending.clear();
111
+ owners.clear();
32
112
  }
@@ -24,7 +24,7 @@
24
24
  // ML-KEM IND-CPA PKE scheme — FIPS 203 Algorithms 12, 13, 14 (K-PKE).
25
25
  // Orchestrates kyber WASM (polynomial math) and sha3 WASM (Keccak sponge).
26
26
  import { wipe } from '../utils.js';
27
- // ── SHA3 helpers ──────────────────────────────────────────────────────────────
27
+ // ── SHA3 helpers ────────────────────────────────────────────────────────────
28
28
  // All operate directly on raw Sha3Exports, no init() system involved.
29
29
  /** Absorb msg into the sha3 sponge in 168-byte chunks (max rate). */
30
30
  function sha3Absorb(sx, msg) {
@@ -77,7 +77,7 @@ export function shake256Hash(sx, msg, n) {
77
77
  }
78
78
  return out;
79
79
  }
80
- // ── Matrix generation ─────────────────────────────────────────────────────────
80
+ // ── Matrix generation ───────────────────────────────────────────────────────
81
81
  /**
82
82
  * Generate row `rowI` of matrix  (or Â^T) into polyvec slot `pvecSlot`.
83
83
  *
@@ -120,7 +120,7 @@ function genMatrixRow(kx, sx, k, rho, transposed, pvecSlot, rowI) {
120
120
  }
121
121
  }
122
122
  }
123
- // ── Noise generation ──────────────────────────────────────────────────────────
123
+ // ── Noise generation ────────────────────────────────────────────────────────
124
124
  /**
125
125
  * CBD noise polyvec: SHAKE256(σ || nonce) for each entry.
126
126
  * FIPS 203 Algorithm 7: PRF_η(σ, N) = SHAKE256(σ || N)[0..64η-1].
@@ -185,7 +185,7 @@ function noisePoly(kx, sx, polyOff, sigma, nonce, eta) {
185
185
  wipe(prfInput);
186
186
  }
187
187
  }
188
- // ── IND-CPA functions ─────────────────────────────────────────────────────────
188
+ // ── IND-CPA functions ───────────────────────────────────────────────────────
189
189
  /**
190
190
  * K-PKE.KeyGen (FIPS 203 Algorithm 12) — deterministic.
191
191
  *
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // ML-KEM public API — MlKem512, MlKem768, MlKem1024 classes.
25
25
  // Uses the init() module cache — call init({ kyber: ..., sha3: ... }) before constructing.
26
- import { getInstance, initModule, isInitialized } from '../init.js';
26
+ import { getInstance, initModule, isInitialized, _assertNotOwned } from '../init.js';
27
27
  import { randomBytes, wipe } from '../utils.js';
28
28
  import { MLKEM512, MLKEM768, MLKEM1024 } from './params.js';
29
29
  import { kemKeypairDerand, kemEncapsulateDerand, kemDecapsulate } from './kem.js';
@@ -44,7 +44,7 @@ export function _kyberReady() {
44
44
  export { MLKEM512, MLKEM768, MLKEM1024 };
45
45
  export { isInitialized };
46
46
  export { KyberSuite } from './suite.js';
47
- // ── Layout assertion ──────────────────────────────────────────────────────────
47
+ // ── Layout assertion ────────────────────────────────────────────────────────
48
48
  function assertLayout(kx, p) {
49
49
  const pk = kx.getPkOffset();
50
50
  const sk = kx.getSkOffset();
@@ -60,7 +60,7 @@ function assertLayout(kx, p) {
60
60
  if (ctPrime + p.ctBytes > xof)
61
61
  throw new Error('leviathan-crypto: kyber buffer overflow — ct_prime overflows into XOF region');
62
62
  }
63
- // ── Base class ────────────────────────────────────────────────────────────────
63
+ // ── Base class ──────────────────────────────────────────────────────────────
64
64
  export class MlKemBase {
65
65
  params;
66
66
  constructor(params) {
@@ -78,6 +78,8 @@ export class MlKemBase {
78
78
  return getInstance('sha3').exports;
79
79
  }
80
80
  keygenDerand(d, z) {
81
+ _assertNotOwned('sha3');
82
+ _assertNotOwned('kyber');
81
83
  if (d.length !== 32)
82
84
  throw new RangeError(`d seed must be 32 bytes (got ${d.length})`);
83
85
  if (z.length !== 32)
@@ -96,10 +98,14 @@ export class MlKemBase {
96
98
  }
97
99
  }
98
100
  encapsulateDerand(ek, m) {
101
+ _assertNotOwned('sha3');
102
+ _assertNotOwned('kyber');
99
103
  if (ek.length !== this.params.ekBytes)
100
104
  throw new RangeError(`encapsulation key must be ${this.params.ekBytes} bytes (got ${ek.length})`);
101
105
  if (m.length !== 32)
102
106
  throw new RangeError(`randomness m must be 32 bytes (got ${m.length})`);
107
+ if (!checkEncapsulationKey(this.kx, this.params, ek))
108
+ throw new RangeError('leviathan-crypto: encapsulation key failed FIPS 203 §7.2 validity check');
103
109
  return kemEncapsulateDerand(this.kx, this.sx, this.params, ek, m);
104
110
  }
105
111
  encapsulate(ek) {
@@ -112,24 +118,38 @@ export class MlKemBase {
112
118
  }
113
119
  }
114
120
  decapsulate(dk, c) {
121
+ _assertNotOwned('sha3');
122
+ _assertNotOwned('kyber');
115
123
  if (dk.length !== this.params.dkBytes)
116
124
  throw new RangeError(`decapsulation key must be ${this.params.dkBytes} bytes (got ${dk.length})`);
117
125
  if (c.length !== this.params.ctBytes)
118
126
  throw new RangeError(`ciphertext must be ${this.params.ctBytes} bytes (got ${c.length})`);
127
+ if (!checkDecapsulationKey(this.kx, this.sx, this.params, dk))
128
+ throw new RangeError('leviathan-crypto: decapsulation key failed FIPS 203 §7.3 validity check');
119
129
  return kemDecapsulate(this.kx, this.sx, this.params, dk, c);
120
130
  }
121
131
  checkEncapsulationKey(ek) {
132
+ _assertNotOwned('sha3');
133
+ _assertNotOwned('kyber');
122
134
  return checkEncapsulationKey(this.kx, this.params, ek);
123
135
  }
124
136
  checkDecapsulationKey(dk) {
137
+ _assertNotOwned('sha3');
138
+ _assertNotOwned('kyber');
125
139
  return checkDecapsulationKey(this.kx, this.sx, this.params, dk);
126
140
  }
127
141
  dispose() {
128
142
  this.kx.wipeBuffers();
129
- this.sx.wipeBuffers();
143
+ // MlKemBase does not own the sha3 module — wiping this.sx.wipeBuffers()
144
+ // here would clobber any SHAKE128/SHAKE256 instance live at the time
145
+ // of dispose(). The wipe is not needed: every public kyber op
146
+ // (keygen, encapsulate, decapsulate, checkDecapsulationKey) calls
147
+ // sx.wipeBuffers() before returning, under the _assertNotOwned('sha3')
148
+ // guard it holds for the duration. sha3 scratch therefore carries no
149
+ // residue across a kyber op boundary — secret-derived or otherwise.
130
150
  }
131
151
  }
132
- // ── Public classes ────────────────────────────────────────────────────────────
152
+ // ── Public classes ──────────────────────────────────────────────────────────
133
153
  /** ML-KEM-512 — k=2, η₁=3, η₂=2, dᵤ=10, dᵥ=4. */
134
154
  export class MlKem512 extends MlKemBase {
135
155
  constructor() {