leviathan-crypto 2.0.1 → 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 (93) hide show
  1. package/CLAUDE.md +171 -7
  2. package/LICENSE +4 -0
  3. package/README.md +109 -54
  4. package/SECURITY.md +125 -238
  5. package/dist/chacha20/cipher-suite.d.ts +10 -0
  6. package/dist/chacha20/cipher-suite.js +65 -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 +66 -26
  18. package/dist/docs/architecture.md +600 -521
  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 +155 -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/fortuna.d.ts +14 -8
  37. package/dist/fortuna.js +144 -50
  38. package/dist/index.d.ts +8 -6
  39. package/dist/index.js +6 -5
  40. package/dist/init.d.ts +0 -2
  41. package/dist/init.js +83 -3
  42. package/dist/kyber/indcpa.js +4 -4
  43. package/dist/kyber/index.js +25 -5
  44. package/dist/kyber/kem.js +56 -1
  45. package/dist/kyber/suite.d.ts +1 -2
  46. package/dist/kyber/types.d.ts +1 -0
  47. package/dist/kyber/validate.d.ts +8 -4
  48. package/dist/kyber/validate.js +18 -14
  49. package/dist/kyber.wasm +0 -0
  50. package/dist/loader.d.ts +7 -2
  51. package/dist/loader.js +25 -28
  52. package/dist/ratchet/index.d.ts +6 -0
  53. package/dist/ratchet/index.js +37 -0
  54. package/dist/ratchet/kdf-chain.d.ts +13 -0
  55. package/dist/ratchet/kdf-chain.js +85 -0
  56. package/dist/ratchet/ratchet-keypair.d.ts +9 -0
  57. package/dist/ratchet/ratchet-keypair.js +61 -0
  58. package/dist/ratchet/root-kdf.d.ts +4 -0
  59. package/dist/ratchet/root-kdf.js +124 -0
  60. package/dist/ratchet/skipped-key-store.d.ts +14 -0
  61. package/dist/ratchet/skipped-key-store.js +154 -0
  62. package/dist/ratchet/types.d.ts +36 -0
  63. package/dist/ratchet/types.js +26 -0
  64. package/dist/serpent/cipher-suite.d.ts +10 -0
  65. package/dist/serpent/cipher-suite.js +135 -50
  66. package/dist/serpent/generator.d.ts +12 -0
  67. package/dist/serpent/generator.js +97 -0
  68. package/dist/serpent/index.d.ts +61 -1
  69. package/dist/serpent/index.js +92 -7
  70. package/dist/serpent/pool-worker.js +25 -101
  71. package/dist/serpent/serpent-cbc.d.ts +14 -4
  72. package/dist/serpent/serpent-cbc.js +50 -32
  73. package/dist/serpent/shared-ops.d.ts +83 -0
  74. package/dist/serpent/shared-ops.js +213 -0
  75. package/dist/serpent/types.d.ts +1 -5
  76. package/dist/sha2/hash.d.ts +2 -0
  77. package/dist/sha2/hash.js +53 -0
  78. package/dist/sha2/index.d.ts +1 -0
  79. package/dist/sha2/index.js +15 -1
  80. package/dist/sha3/hash.d.ts +2 -0
  81. package/dist/sha3/hash.js +53 -0
  82. package/dist/sha3/index.d.ts +17 -2
  83. package/dist/sha3/index.js +79 -7
  84. package/dist/stream/header.js +5 -5
  85. package/dist/stream/open-stream.js +36 -14
  86. package/dist/stream/seal-stream-pool.d.ts +1 -0
  87. package/dist/stream/seal-stream-pool.js +38 -8
  88. package/dist/stream/seal-stream.js +29 -11
  89. package/dist/types.d.ts +21 -0
  90. package/dist/utils.d.ts +7 -8
  91. package/dist/utils.js +73 -40
  92. package/dist/wasm-source.d.ts +9 -8
  93. package/package.json +79 -64
@@ -26,13 +26,20 @@ export declare class SHA3_224 {
26
26
  hash(msg: Uint8Array): Uint8Array;
27
27
  dispose(): void;
28
28
  }
29
- /** SHAKE128 XOF — extendable output, multi-squeeze capable. */
29
+ /**
30
+ * SHAKE128 XOF — extendable output, multi-squeeze capable.
31
+ *
32
+ * Holds exclusive access to the `sha3` WASM module from construction until
33
+ * `dispose()`. Constructing a second SHAKE128/SHAKE256 or any other sha3
34
+ * user while this instance is live throws. Call `dispose()` when done.
35
+ */
30
36
  export declare class SHAKE128 {
31
37
  private readonly x;
32
38
  private readonly _rate;
33
39
  private _squeezing;
34
40
  private _block;
35
41
  private _blockPos;
42
+ private _tok;
36
43
  constructor();
37
44
  reset(): this;
38
45
  absorb(msg: Uint8Array): this;
@@ -40,13 +47,20 @@ export declare class SHAKE128 {
40
47
  hash(msg: Uint8Array, outputLength: number): Uint8Array;
41
48
  dispose(): void;
42
49
  }
43
- /** SHAKE256 XOF — extendable output, multi-squeeze capable. */
50
+ /**
51
+ * SHAKE256 XOF — extendable output, multi-squeeze capable.
52
+ *
53
+ * Holds exclusive access to the `sha3` WASM module from construction until
54
+ * `dispose()`. Constructing a second SHAKE128/SHAKE256 or any other sha3
55
+ * user while this instance is live throws. Call `dispose()` when done.
56
+ */
44
57
  export declare class SHAKE256 {
45
58
  private readonly x;
46
59
  private readonly _rate;
47
60
  private _squeezing;
48
61
  private _block;
49
62
  private _blockPos;
63
+ private _tok;
50
64
  constructor();
51
65
  reset(): this;
52
66
  absorb(msg: Uint8Array): this;
@@ -54,3 +68,4 @@ export declare class SHAKE256 {
54
68
  hash(msg: Uint8Array, outputLength: number): Uint8Array;
55
69
  dispose(): void;
56
70
  }
71
+ export { SHA3_256Hash } from './hash.js';
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // Public API classes for the SHA-3 WASM module.
25
25
  // Uses the init() module cache — call sha3Init(source) before constructing.
26
- import { getInstance, initModule } from '../init.js';
26
+ import { getInstance, initModule, _acquireModule, _releaseModule, _assertNotOwned } from '../init.js';
27
27
  export async function sha3Init(source) {
28
28
  return initModule('sha3', source);
29
29
  }
@@ -58,6 +58,7 @@ export class SHA3_256 {
58
58
  this.x = getExports();
59
59
  }
60
60
  hash(msg) {
61
+ _assertNotOwned('sha3');
61
62
  this.x.sha3_256Init();
62
63
  absorb(this.x, msg);
63
64
  this.x.sha3_256Final();
@@ -65,6 +66,7 @@ export class SHA3_256 {
65
66
  return mem.slice(this.x.getOutOffset(), this.x.getOutOffset() + 32);
66
67
  }
67
68
  dispose() {
69
+ _assertNotOwned('sha3');
68
70
  this.x.wipeBuffers();
69
71
  }
70
72
  }
@@ -75,6 +77,7 @@ export class SHA3_512 {
75
77
  this.x = getExports();
76
78
  }
77
79
  hash(msg) {
80
+ _assertNotOwned('sha3');
78
81
  this.x.sha3_512Init();
79
82
  absorb(this.x, msg);
80
83
  this.x.sha3_512Final();
@@ -82,6 +85,7 @@ export class SHA3_512 {
82
85
  return mem.slice(this.x.getOutOffset(), this.x.getOutOffset() + 64);
83
86
  }
84
87
  dispose() {
88
+ _assertNotOwned('sha3');
85
89
  this.x.wipeBuffers();
86
90
  }
87
91
  }
@@ -92,6 +96,7 @@ export class SHA3_384 {
92
96
  this.x = getExports();
93
97
  }
94
98
  hash(msg) {
99
+ _assertNotOwned('sha3');
95
100
  this.x.sha3_384Init();
96
101
  absorb(this.x, msg);
97
102
  this.x.sha3_384Final();
@@ -99,6 +104,7 @@ export class SHA3_384 {
99
104
  return mem.slice(this.x.getOutOffset(), this.x.getOutOffset() + 48);
100
105
  }
101
106
  dispose() {
107
+ _assertNotOwned('sha3');
102
108
  this.x.wipeBuffers();
103
109
  }
104
110
  }
@@ -109,6 +115,7 @@ export class SHA3_224 {
109
115
  this.x = getExports();
110
116
  }
111
117
  hash(msg) {
118
+ _assertNotOwned('sha3');
112
119
  this.x.sha3_224Init();
113
120
  absorb(this.x, msg);
114
121
  this.x.sha3_224Final();
@@ -116,22 +123,40 @@ export class SHA3_224 {
116
123
  return mem.slice(this.x.getOutOffset(), this.x.getOutOffset() + 28);
117
124
  }
118
125
  dispose() {
126
+ _assertNotOwned('sha3');
119
127
  this.x.wipeBuffers();
120
128
  }
121
129
  }
122
130
  // ── SHAKE128 ────────────────────────────────────────────────────────────────
123
- /** SHAKE128 XOF — extendable output, multi-squeeze capable. */
131
+ /**
132
+ * SHAKE128 XOF — extendable output, multi-squeeze capable.
133
+ *
134
+ * Holds exclusive access to the `sha3` WASM module from construction until
135
+ * `dispose()`. Constructing a second SHAKE128/SHAKE256 or any other sha3
136
+ * user while this instance is live throws. Call `dispose()` when done.
137
+ */
124
138
  export class SHAKE128 {
125
139
  x;
126
140
  _rate = 168;
127
141
  _squeezing = false;
128
142
  _block = new Uint8Array(168);
129
143
  _blockPos = 168;
144
+ _tok;
130
145
  constructor() {
131
146
  this.x = getExports();
132
- this.x.shake128Init();
147
+ this._tok = _acquireModule('sha3');
148
+ try {
149
+ this.x.shake128Init();
150
+ }
151
+ catch (e) {
152
+ _releaseModule('sha3', this._tok);
153
+ this._tok = undefined;
154
+ throw e;
155
+ }
133
156
  }
134
157
  reset() {
158
+ if (this._tok === undefined)
159
+ throw new Error('SHAKE128: instance has been disposed');
135
160
  this.x.shake128Init();
136
161
  this._squeezing = false;
137
162
  this._block.fill(0);
@@ -139,12 +164,16 @@ export class SHAKE128 {
139
164
  return this;
140
165
  }
141
166
  absorb(msg) {
167
+ if (this._tok === undefined)
168
+ throw new Error('SHAKE128: instance has been disposed');
142
169
  if (this._squeezing)
143
170
  throw new Error('SHAKE128: cannot absorb after squeeze — call reset() first');
144
171
  absorb(this.x, msg);
145
172
  return this;
146
173
  }
147
174
  squeeze(n) {
175
+ if (this._tok === undefined)
176
+ throw new Error('SHAKE128: instance has been disposed');
148
177
  if (n < 1)
149
178
  throw new RangeError(`squeeze length must be >= 1 (got ${n})`);
150
179
  if (!this._squeezing) {
@@ -170,6 +199,8 @@ export class SHAKE128 {
170
199
  return out;
171
200
  }
172
201
  hash(msg, outputLength) {
202
+ if (this._tok === undefined)
203
+ throw new Error('SHAKE128: instance has been disposed');
173
204
  if (outputLength < 1)
174
205
  throw new RangeError(`outputLength must be >= 1 (got ${outputLength})`);
175
206
  this.reset();
@@ -177,23 +208,48 @@ export class SHAKE128 {
177
208
  return this.squeeze(outputLength);
178
209
  }
179
210
  dispose() {
211
+ if (this._tok === undefined)
212
+ return;
180
213
  this._block.fill(0);
181
- this.x.wipeBuffers();
214
+ try {
215
+ this.x.wipeBuffers();
216
+ }
217
+ finally {
218
+ _releaseModule('sha3', this._tok);
219
+ this._tok = undefined;
220
+ }
182
221
  }
183
222
  }
184
223
  // ── SHAKE256 ────────────────────────────────────────────────────────────────
185
- /** SHAKE256 XOF — extendable output, multi-squeeze capable. */
224
+ /**
225
+ * SHAKE256 XOF — extendable output, multi-squeeze capable.
226
+ *
227
+ * Holds exclusive access to the `sha3` WASM module from construction until
228
+ * `dispose()`. Constructing a second SHAKE128/SHAKE256 or any other sha3
229
+ * user while this instance is live throws. Call `dispose()` when done.
230
+ */
186
231
  export class SHAKE256 {
187
232
  x;
188
233
  _rate = 136;
189
234
  _squeezing = false;
190
235
  _block = new Uint8Array(136);
191
236
  _blockPos = 136;
237
+ _tok;
192
238
  constructor() {
193
239
  this.x = getExports();
194
- this.x.shake256Init();
240
+ this._tok = _acquireModule('sha3');
241
+ try {
242
+ this.x.shake256Init();
243
+ }
244
+ catch (e) {
245
+ _releaseModule('sha3', this._tok);
246
+ this._tok = undefined;
247
+ throw e;
248
+ }
195
249
  }
196
250
  reset() {
251
+ if (this._tok === undefined)
252
+ throw new Error('SHAKE256: instance has been disposed');
197
253
  this.x.shake256Init();
198
254
  this._squeezing = false;
199
255
  this._block.fill(0);
@@ -201,12 +257,16 @@ export class SHAKE256 {
201
257
  return this;
202
258
  }
203
259
  absorb(msg) {
260
+ if (this._tok === undefined)
261
+ throw new Error('SHAKE256: instance has been disposed');
204
262
  if (this._squeezing)
205
263
  throw new Error('SHAKE256: cannot absorb after squeeze — call reset() first');
206
264
  absorb(this.x, msg);
207
265
  return this;
208
266
  }
209
267
  squeeze(n) {
268
+ if (this._tok === undefined)
269
+ throw new Error('SHAKE256: instance has been disposed');
210
270
  if (n < 1)
211
271
  throw new RangeError(`squeeze length must be >= 1 (got ${n})`);
212
272
  if (!this._squeezing) {
@@ -232,6 +292,8 @@ export class SHAKE256 {
232
292
  return out;
233
293
  }
234
294
  hash(msg, outputLength) {
295
+ if (this._tok === undefined)
296
+ throw new Error('SHAKE256: instance has been disposed');
235
297
  if (outputLength < 1)
236
298
  throw new RangeError(`outputLength must be >= 1 (got ${outputLength})`);
237
299
  this.reset();
@@ -239,7 +301,17 @@ export class SHAKE256 {
239
301
  return this.squeeze(outputLength);
240
302
  }
241
303
  dispose() {
304
+ if (this._tok === undefined)
305
+ return;
242
306
  this._block.fill(0);
243
- this.x.wipeBuffers();
307
+ try {
308
+ this.x.wipeBuffers();
309
+ }
310
+ finally {
311
+ _releaseModule('sha3', this._tok);
312
+ this._tok = undefined;
313
+ }
244
314
  }
245
315
  }
316
+ // ── SHA3_256Hash ────────────────────────────────────────────────────────────
317
+ export { SHA3_256Hash } from './hash.js';
@@ -22,7 +22,7 @@
22
22
  // src/ts/stream/header.ts
23
23
  //
24
24
  // Wire format header encoding/decoding and counter nonce construction.
25
- import { FLAG_FRAMED, HEADER_SIZE, TAG_DATA, TAG_FINAL } from './constants.js';
25
+ import { CHUNK_MAX, CHUNK_MIN, FLAG_FRAMED, HEADER_SIZE, TAG_DATA, TAG_FINAL } from './constants.js';
26
26
  // The 16-byte nonce is a HKDF salt — not a direct cipher nonce.
27
27
  // Both XChaCha20Cipher and SerpentCipher derive their actual key material
28
28
  // and nonces from this value via HKDF-SHA-256. The 16-byte size is chosen
@@ -33,8 +33,8 @@ export function writeHeader(formatEnum, framed, nonce, chunkSize) {
33
33
  throw new RangeError(`formatEnum must be an integer in [0, 0x3f] (got ${formatEnum})`);
34
34
  if (nonce.length !== 16)
35
35
  throw new RangeError(`nonce must be 16 bytes (got ${nonce.length})`);
36
- if (!Number.isInteger(chunkSize) || chunkSize < 0 || chunkSize > 0xffffff)
37
- throw new RangeError(`chunkSize must be an integer in [0, 0xFFFFFF] (got ${chunkSize})`);
36
+ if (!Number.isInteger(chunkSize) || chunkSize < CHUNK_MIN || chunkSize > CHUNK_MAX)
37
+ throw new RangeError(`chunkSize must be an integer in [${CHUNK_MIN}, ${CHUNK_MAX}] (got ${chunkSize})`);
38
38
  const h = new Uint8Array(HEADER_SIZE);
39
39
  h[0] = (framed ? FLAG_FRAMED : 0) | formatEnum;
40
40
  h.set(nonce, 1);
@@ -59,8 +59,8 @@ export function readHeader(header) {
59
59
  }
60
60
  /** 12-byte counter nonce: 11-byte BE counter + 1-byte final flag. */
61
61
  export function makeCounterNonce(counter, finalFlag) {
62
- if (!Number.isInteger(counter) || counter < 0 || counter > Number.MAX_SAFE_INTEGER)
63
- throw new RangeError(`counter must be an integer in [0, ${Number.MAX_SAFE_INTEGER}]`);
62
+ if (!Number.isSafeInteger(counter) || counter < 0)
63
+ throw new RangeError(`counter must be a safe integer in [0, ${Number.MAX_SAFE_INTEGER}]`);
64
64
  if (finalFlag !== TAG_DATA && finalFlag !== TAG_FINAL)
65
65
  throw new RangeError(`finalFlag must be TAG_DATA (0x00) or TAG_FINAL (0x01) (got 0x${finalFlag.toString(16).padStart(2, '0')})`);
66
66
  const n = new Uint8Array(12);
@@ -65,42 +65,64 @@ export class OpenStream {
65
65
  }
66
66
  pull(chunk, opts) {
67
67
  if (this.state !== 'ready')
68
- throw new Error('OpenStream: cannot pull after finalize');
68
+ throw new Error(`OpenStream: cannot pull in state '${this.state}'`);
69
+ // Argument and wire-format validation runs before the crypto-failure
70
+ // try/catch so a malformed chunk throws without wiping keys or
71
+ // transitioning to 'failed'. The caller can retry with a corrected
72
+ // chunk. Symmetric with SealStream.push.
69
73
  const data = this.framed ? this._stripFrame(chunk) : chunk;
70
74
  if (data.length < this.cipher.tagSize)
71
75
  throw new RangeError(`chunk too short to contain ${this.cipher.tagSize}-byte tag (got ${data.length} bytes)`);
72
76
  if (data.length > this.maxWireChunk)
73
77
  throw new RangeError(`chunk exceeds max wire size (${data.length} > ${this.maxWireChunk})`);
74
- const nonce = makeCounterNonce(this.counter, TAG_DATA);
75
- const plaintext = this.cipher.openChunk(this.keys, nonce, data, opts?.aad);
76
- this.counter++;
77
- return plaintext;
78
+ try {
79
+ const nonce = makeCounterNonce(this.counter, TAG_DATA);
80
+ const plaintext = this.cipher.openChunk(this.keys, nonce, data, opts?.aad);
81
+ this.counter++;
82
+ return plaintext;
83
+ }
84
+ catch (err) {
85
+ this.cipher.wipeKeys(this.keys);
86
+ this.state = 'failed';
87
+ throw err;
88
+ }
78
89
  }
79
90
  finalize(chunk, opts) {
80
91
  if (this.state !== 'ready')
81
- throw new Error('OpenStream: already finalized');
92
+ throw new Error(`OpenStream: cannot finalize in state '${this.state}'`);
82
93
  const data = this.framed ? this._stripFrame(chunk) : chunk;
83
94
  if (data.length < this.cipher.tagSize)
84
95
  throw new RangeError(`chunk too short to contain ${this.cipher.tagSize}-byte tag (got ${data.length} bytes)`);
85
96
  if (data.length > this.maxWireChunk)
86
97
  throw new RangeError(`chunk exceeds max wire size (${data.length} > ${this.maxWireChunk})`);
87
- const nonce = makeCounterNonce(this.counter, TAG_FINAL);
88
- const plaintext = this.cipher.openChunk(this.keys, nonce, data, opts?.aad);
89
- this.cipher.wipeKeys(this.keys);
90
- this.state = 'finalized';
91
- return plaintext;
98
+ try {
99
+ const nonce = makeCounterNonce(this.counter, TAG_FINAL);
100
+ const plaintext = this.cipher.openChunk(this.keys, nonce, data, opts?.aad);
101
+ this.cipher.wipeKeys(this.keys);
102
+ this.state = 'finalized';
103
+ return plaintext;
104
+ }
105
+ catch (err) {
106
+ this.cipher.wipeKeys(this.keys);
107
+ this.state = 'failed';
108
+ throw err;
109
+ }
92
110
  }
93
111
  dispose() {
94
112
  if (this.state === 'ready') {
95
113
  this.cipher.wipeKeys(this.keys);
96
114
  this.state = 'finalized';
97
115
  }
116
+ // 'failed' already wiped keys; 'finalized' already wiped keys — no-op.
98
117
  }
99
118
  seek(index) {
100
119
  if (this.state !== 'ready')
101
- throw new Error('OpenStream: cannot seek after finalize');
102
- if (!Number.isInteger(index) || index < 0)
103
- throw new RangeError(`seek index must be a non-negative integer (got ${index})`);
120
+ throw new Error(`OpenStream: cannot seek in state '${this.state}'`);
121
+ if (!Number.isSafeInteger(index) || index < 0)
122
+ throw new RangeError(`seek index must be a non-negative safe integer ≤ Number.MAX_SAFE_INTEGER (got ${index})`);
123
+ if (index < this.counter)
124
+ throw new RangeError(`OpenStream: seek is forward-only — current counter ${this.counter}, requested ${index}. `
125
+ + 'Backward seeks would permit plaintext replay; construct a new OpenStream to restart.');
104
126
  this.counter = index;
105
127
  }
106
128
  _stripFrame(chunk) {
@@ -35,4 +35,5 @@ export declare class SealStreamPool {
35
35
  private _onMessage;
36
36
  private _onError;
37
37
  private _killAll;
38
+ private _wipeThenTerminate;
38
39
  }
@@ -379,15 +379,14 @@ export class SealStreamPool {
379
379
  reject(error);
380
380
  this._pending.clear();
381
381
  this._queue.length = 0;
382
- for (const w of this._workers) {
383
- try {
384
- w.postMessage({ type: 'wipe' });
385
- }
386
- catch { /* worker may be terminated */ }
387
- w.terminate();
388
- }
389
- this._workers.length = 0;
382
+ const workers = this._workers;
383
+ this._workers = [];
390
384
  this._idle.length = 0;
385
+ // Fire-and-forget: wipe each worker's key material, then terminate.
386
+ // On timeout, terminate anyway — the main-thread key handles are
387
+ // wiped below so the owning surface no longer has access.
388
+ for (const w of workers)
389
+ this._wipeThenTerminate(w);
391
390
  if (this._keys) {
392
391
  wipe(this._keys.bytes);
393
392
  this._keys = null;
@@ -397,4 +396,35 @@ export class SealStreamPool {
397
396
  this._masterKey = null;
398
397
  }
399
398
  }
399
+ _wipeThenTerminate(w) {
400
+ const WIPE_ACK_TIMEOUT_MS = 100;
401
+ let done = false;
402
+ // prefer-const false positive: `finish` (defined below) closes over
403
+ // `t` and is invoked synchronously from the catch path before the
404
+ // `t = setTimeout(...)` assignment runs.
405
+ // eslint-disable-next-line prefer-const
406
+ let t;
407
+ const finish = () => {
408
+ if (done)
409
+ return;
410
+ done = true;
411
+ if (t !== undefined)
412
+ clearTimeout(t);
413
+ w.removeEventListener('message', onMsg);
414
+ w.terminate();
415
+ };
416
+ const onMsg = (e) => {
417
+ if (e.data && e.data.type === 'wiped')
418
+ finish();
419
+ };
420
+ w.addEventListener('message', onMsg);
421
+ try {
422
+ w.postMessage({ type: 'wipe' });
423
+ }
424
+ catch {
425
+ finish();
426
+ return;
427
+ }
428
+ t = setTimeout(finish, WIPE_ACK_TIMEOUT_MS);
429
+ }
400
430
  }
@@ -80,30 +80,48 @@ export class SealStream {
80
80
  }
81
81
  push(chunk, opts) {
82
82
  if (this.state !== 'ready')
83
- throw new Error('SealStream: cannot push after finalize');
83
+ throw new Error(`SealStream: cannot push in state '${this.state}'`);
84
+ // Argument validation runs before the crypto-failure try/catch so a
85
+ // too-big chunk throws without wiping keys or transitioning to 'failed'.
86
+ // The caller can retry with a correctly-sized chunk.
84
87
  if (chunk.length > this.chunkSize)
85
88
  throw new RangeError(`chunk exceeds chunkSize (${chunk.length} > ${this.chunkSize})`);
86
- const nonce = makeCounterNonce(this.counter, TAG_DATA);
87
- const result = this.cipher.sealChunk(this.keys, nonce, chunk, opts?.aad);
88
- this.counter++;
89
- return this.framed ? concat(u32beFrame(result.length), result) : result;
89
+ try {
90
+ const nonce = makeCounterNonce(this.counter, TAG_DATA);
91
+ const result = this.cipher.sealChunk(this.keys, nonce, chunk, opts?.aad);
92
+ this.counter++;
93
+ return this.framed ? concat(u32beFrame(result.length), result) : result;
94
+ }
95
+ catch (err) {
96
+ this.cipher.wipeKeys(this.keys);
97
+ this.state = 'failed';
98
+ throw err;
99
+ }
90
100
  }
91
101
  finalize(chunk, opts) {
92
102
  if (this.state !== 'ready')
93
- throw new Error('SealStream: already finalized');
103
+ throw new Error(`SealStream: cannot finalize in state '${this.state}'`);
94
104
  if (chunk.length > this.chunkSize)
95
105
  throw new RangeError(`chunk exceeds chunkSize (${chunk.length} > ${this.chunkSize})`);
96
- const nonce = makeCounterNonce(this.counter, TAG_FINAL);
97
- const result = this.cipher.sealChunk(this.keys, nonce, chunk, opts?.aad);
98
- this.cipher.wipeKeys(this.keys);
99
- this.state = 'finalized';
100
- return this.framed ? concat(u32beFrame(result.length), result) : result;
106
+ try {
107
+ const nonce = makeCounterNonce(this.counter, TAG_FINAL);
108
+ const result = this.cipher.sealChunk(this.keys, nonce, chunk, opts?.aad);
109
+ this.cipher.wipeKeys(this.keys);
110
+ this.state = 'finalized';
111
+ return this.framed ? concat(u32beFrame(result.length), result) : result;
112
+ }
113
+ catch (err) {
114
+ this.cipher.wipeKeys(this.keys);
115
+ this.state = 'failed';
116
+ throw err;
117
+ }
101
118
  }
102
119
  dispose() {
103
120
  if (this.state === 'ready') {
104
121
  this.cipher.wipeKeys(this.keys);
105
122
  this.state = 'finalized';
106
123
  }
124
+ // 'failed' already wiped keys; 'finalized' already wiped keys — no-op.
107
125
  }
108
126
  toTransformStream() {
109
127
  let headerSent = false;
package/dist/types.d.ts CHANGED
@@ -22,3 +22,24 @@ export interface AEAD {
22
22
  decrypt(ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array;
23
23
  dispose(): void;
24
24
  }
25
+ /**
26
+ * Stateless cipher PRF for Fortuna's generator slot. Produces `n` bytes of
27
+ * keystream from `(key, counter)` without mutating either input. Implementations
28
+ * are plain const objects, not classes.
29
+ */
30
+ export interface Generator {
31
+ readonly keySize: number;
32
+ readonly blockSize: number;
33
+ readonly counterSize: number;
34
+ readonly wasmModules: readonly string[];
35
+ generate(key: Uint8Array, counter: Uint8Array, n: number): Uint8Array;
36
+ }
37
+ /**
38
+ * Stateless hash function for Fortuna's accumulator and reseed slots. Output
39
+ * size must match the generator's key size when paired in Fortuna.
40
+ */
41
+ export interface HashFn {
42
+ readonly outputSize: number;
43
+ readonly wasmModules: readonly string[];
44
+ digest(msg: Uint8Array): Uint8Array;
45
+ }
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** Hex string to Uint8Array. Accepts lowercase/uppercase, optional 0x prefix. Throws RangeError on odd-length input. */
1
+ /** Hex string to Uint8Array. Accepts lowercase/uppercase, optional 0x prefix. Throws RangeError on odd-length or non-hex input. */
2
2
  export declare const hexToBytes: (hex: string) => Uint8Array;
3
3
  /** Uint8Array to lowercase hex string. */
4
4
  export declare const bytesToHex: (bytes: Uint8Array) => string;
@@ -6,18 +6,17 @@ export declare const bytesToHex: (bytes: Uint8Array) => string;
6
6
  export declare const utf8ToBytes: (str: string) => Uint8Array;
7
7
  /** Uint8Array to UTF-8 string. */
8
8
  export declare const bytesToUtf8: (bytes: Uint8Array) => string;
9
- /** Base64 or base64url string to Uint8Array. Handles padded, unpadded, and legacy %3d padding. Returns undefined on invalid input. */
10
- export declare const base64ToBytes: (b64: string) => Uint8Array | undefined;
9
+ /** Base64 or base64url string to Uint8Array. Handles padded, unpadded, and legacy %3d padding. Throws RangeError on invalid input. */
10
+ export declare const base64ToBytes: (b64: string) => Uint8Array;
11
11
  /** Uint8Array to base64 string. Pass url=true for base64url (RFC 4648 §5 — no padding characters). */
12
12
  export declare const bytesToBase64: (bytes: Uint8Array, url?: boolean) => string;
13
13
  export declare const CT_MAX_BYTES = 32768;
14
14
  /**
15
15
  * Constant-time byte-array equality.
16
- * Uses WASM SIMD when available (no JIT short-circuiting, no speculative
17
- * optimization). Falls back to a JS XOR-accumulate loop on runtimes
18
- * without SIMD support.
19
- * Length check is not constant-time (length is non-secret in all protocols).
20
- * Max input size: 32768 bytes per side (enforced regardless of code path).
16
+ * Runs entirely inside a WASM SIMD module (v128 XOR accumulate with
17
+ * branch-free reduction). Throws on runtimes without SIMD support
18
+ * no JS fallback. Length check is not constant-time (length is
19
+ * non-secret in all protocols). Max input size: 32768 bytes per side.
21
20
  */
22
21
  export declare const constantTimeEqual: (a: Uint8Array, b: Uint8Array) => boolean;
23
22
  /** Zero a typed array in place. */