leviathan-crypto 1.4.0 → 2.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.
Files changed (119) hide show
  1. package/CLAUDE.md +129 -94
  2. package/README.md +166 -223
  3. package/SECURITY.md +85 -45
  4. package/dist/chacha20/cipher-suite.d.ts +4 -0
  5. package/dist/chacha20/cipher-suite.js +78 -0
  6. package/dist/chacha20/embedded.d.ts +1 -0
  7. package/dist/chacha20/embedded.js +27 -0
  8. package/dist/chacha20/index.d.ts +20 -27
  9. package/dist/chacha20/index.js +40 -59
  10. package/dist/chacha20/ops.d.ts +1 -1
  11. package/dist/chacha20/ops.js +19 -18
  12. package/dist/chacha20/pool-worker.js +77 -0
  13. package/dist/ct-wasm.d.ts +1 -0
  14. package/dist/ct-wasm.js +3 -0
  15. package/dist/ct.wasm +0 -0
  16. package/dist/docs/aead.md +320 -0
  17. package/dist/docs/architecture.md +419 -285
  18. package/dist/docs/argon2id.md +42 -30
  19. package/dist/docs/chacha20.md +192 -266
  20. package/dist/docs/exports.md +241 -0
  21. package/dist/docs/fortuna.md +60 -69
  22. package/dist/docs/init.md +172 -178
  23. package/dist/docs/loader.md +87 -142
  24. package/dist/docs/serpent.md +134 -583
  25. package/dist/docs/sha2.md +91 -103
  26. package/dist/docs/sha3.md +70 -36
  27. package/dist/docs/types.md +93 -16
  28. package/dist/docs/utils.md +109 -32
  29. package/dist/embedded/kyber.d.ts +1 -0
  30. package/dist/embedded/kyber.js +3 -0
  31. package/dist/errors.d.ts +10 -0
  32. package/dist/errors.js +38 -0
  33. package/dist/fortuna.d.ts +0 -6
  34. package/dist/fortuna.js +5 -5
  35. package/dist/index.d.ts +25 -9
  36. package/dist/index.js +36 -7
  37. package/dist/init.d.ts +3 -7
  38. package/dist/init.js +18 -35
  39. package/dist/keccak/embedded.d.ts +1 -0
  40. package/dist/keccak/embedded.js +27 -0
  41. package/dist/keccak/index.d.ts +4 -0
  42. package/dist/keccak/index.js +31 -0
  43. package/dist/kyber/embedded.d.ts +1 -0
  44. package/dist/kyber/embedded.js +27 -0
  45. package/dist/kyber/indcpa.d.ts +49 -0
  46. package/dist/kyber/indcpa.js +352 -0
  47. package/dist/kyber/index.d.ts +38 -0
  48. package/dist/kyber/index.js +150 -0
  49. package/dist/kyber/kem.d.ts +21 -0
  50. package/dist/kyber/kem.js +160 -0
  51. package/dist/kyber/params.d.ts +14 -0
  52. package/dist/kyber/params.js +37 -0
  53. package/dist/kyber/suite.d.ts +13 -0
  54. package/dist/kyber/suite.js +93 -0
  55. package/dist/kyber/types.d.ts +98 -0
  56. package/dist/kyber/types.js +25 -0
  57. package/dist/kyber/validate.d.ts +19 -0
  58. package/dist/kyber/validate.js +68 -0
  59. package/dist/kyber.wasm +0 -0
  60. package/dist/loader.d.ts +15 -6
  61. package/dist/loader.js +65 -21
  62. package/dist/serpent/cipher-suite.d.ts +4 -0
  63. package/dist/serpent/cipher-suite.js +121 -0
  64. package/dist/serpent/embedded.d.ts +1 -0
  65. package/dist/serpent/embedded.js +27 -0
  66. package/dist/serpent/index.d.ts +6 -37
  67. package/dist/serpent/index.js +9 -118
  68. package/dist/serpent/pool-worker.d.ts +1 -0
  69. package/dist/serpent/pool-worker.js +202 -0
  70. package/dist/serpent/serpent-cbc.d.ts +30 -0
  71. package/dist/serpent/serpent-cbc.js +136 -0
  72. package/dist/sha2/embedded.d.ts +1 -0
  73. package/dist/sha2/embedded.js +27 -0
  74. package/dist/sha2/hkdf.js +6 -2
  75. package/dist/sha2/index.d.ts +3 -2
  76. package/dist/sha2/index.js +3 -4
  77. package/dist/sha3/embedded.d.ts +1 -0
  78. package/dist/sha3/embedded.js +27 -0
  79. package/dist/sha3/index.d.ts +3 -2
  80. package/dist/sha3/index.js +3 -4
  81. package/dist/stream/constants.d.ts +6 -0
  82. package/dist/stream/constants.js +30 -0
  83. package/dist/stream/header.d.ts +9 -0
  84. package/dist/stream/header.js +77 -0
  85. package/dist/stream/index.d.ts +7 -0
  86. package/dist/stream/index.js +27 -0
  87. package/dist/stream/open-stream.d.ts +21 -0
  88. package/dist/stream/open-stream.js +146 -0
  89. package/dist/stream/seal-stream-pool.d.ts +38 -0
  90. package/dist/stream/seal-stream-pool.js +391 -0
  91. package/dist/stream/seal-stream.d.ts +20 -0
  92. package/dist/stream/seal-stream.js +142 -0
  93. package/dist/stream/seal.d.ts +9 -0
  94. package/dist/stream/seal.js +75 -0
  95. package/dist/stream/types.d.ts +24 -0
  96. package/dist/stream/types.js +26 -0
  97. package/dist/utils.d.ts +7 -2
  98. package/dist/utils.js +49 -3
  99. package/dist/wasm-source.d.ts +12 -0
  100. package/dist/wasm-source.js +26 -0
  101. package/package.json +13 -5
  102. package/dist/chacha20/pool.d.ts +0 -52
  103. package/dist/chacha20/pool.js +0 -178
  104. package/dist/chacha20/pool.worker.js +0 -37
  105. package/dist/chacha20/stream-sealer.d.ts +0 -49
  106. package/dist/chacha20/stream-sealer.js +0 -327
  107. package/dist/docs/chacha20_pool.md +0 -309
  108. package/dist/docs/wasm.md +0 -194
  109. package/dist/serpent/seal.d.ts +0 -8
  110. package/dist/serpent/seal.js +0 -72
  111. package/dist/serpent/stream-pool.d.ts +0 -48
  112. package/dist/serpent/stream-pool.js +0 -275
  113. package/dist/serpent/stream-sealer.d.ts +0 -55
  114. package/dist/serpent/stream-sealer.js +0 -342
  115. package/dist/serpent/stream.d.ts +0 -28
  116. package/dist/serpent/stream.js +0 -205
  117. package/dist/serpent/stream.worker.d.ts +0 -32
  118. package/dist/serpent/stream.worker.js +0 -117
  119. /package/dist/chacha20/{pool.worker.d.ts → pool-worker.d.ts} +0 -0
@@ -1,178 +0,0 @@
1
- // src/ts/chacha20/pool.ts
2
- //
3
- // XChaCha20Poly1305Pool — parallel worker pool for XChaCha20-Poly1305 AEAD.
4
- // Dispatches independent encrypt/decrypt jobs across Web Workers, each with
5
- // its own WebAssembly.Instance and isolated linear memory.
6
- import { isInitialized } from '../init.js';
7
- import { decodeWasm } from '../loader.js';
8
- // ── WASM module singleton ────────────────────────────────────────────────────
9
- let _wasmModule;
10
- async function getWasmModule() {
11
- if (_wasmModule)
12
- return _wasmModule;
13
- const { WASM_GZ_BASE64 } = await import('../embedded/chacha20.js');
14
- const bytes = await decodeWasm(WASM_GZ_BASE64);
15
- _wasmModule = await WebAssembly.compile(bytes.buffer);
16
- return _wasmModule;
17
- }
18
- // ── Worker spawning ──────────────────────────────────────────────────────────
19
- function spawnWorker(mod) {
20
- return new Promise((resolve, reject) => {
21
- const worker = new Worker(new URL('./pool.worker.js', import.meta.url), { type: 'module' });
22
- const onMessage = (e) => {
23
- cleanup();
24
- if (e.data.type === 'ready') {
25
- resolve(worker);
26
- }
27
- else {
28
- worker.terminate();
29
- reject(new Error(`leviathan-crypto: worker init failed: ${e.data.message}`));
30
- }
31
- };
32
- const onError = (e) => {
33
- cleanup();
34
- worker.terminate();
35
- reject(new Error(`leviathan-crypto: worker init failed: ${e.message}`));
36
- };
37
- const cleanup = () => {
38
- worker.removeEventListener('message', onMessage);
39
- worker.removeEventListener('error', onError);
40
- };
41
- worker.addEventListener('message', onMessage);
42
- worker.addEventListener('error', onError);
43
- worker.postMessage({ type: 'init', module: mod });
44
- });
45
- }
46
- // ── Pool class ───────────────────────────────────────────────────────────────
47
- /**
48
- * Parallel worker pool for XChaCha20-Poly1305 AEAD.
49
- *
50
- * Each worker owns its own `WebAssembly.Instance` with isolated linear memory.
51
- * Jobs are dispatched round-robin to idle workers; excess jobs queue until a
52
- * worker frees up.
53
- *
54
- * **Warning:** Input buffers (`key`, `nonce`, `plaintext`/`ciphertext`, `aad`)
55
- * are transferred to the worker and neutered on the calling side. The caller
56
- * must copy any buffer they need to retain after calling `encrypt()`/`decrypt()`.
57
- */
58
- export class XChaCha20Poly1305Pool {
59
- _workers;
60
- _idle;
61
- _queue;
62
- _pending;
63
- _nextId;
64
- _disposed;
65
- constructor(workers) {
66
- this._workers = workers;
67
- this._idle = [...workers];
68
- this._queue = [];
69
- this._pending = new Map();
70
- this._nextId = 0;
71
- this._disposed = false;
72
- for (const w of workers) {
73
- w.onmessage = (e) => this._onMessage(w, e);
74
- }
75
- }
76
- /**
77
- * Create a new pool. Requires `init(['chacha20'])` to have been called.
78
- * Compiles the WASM module once and distributes it to all workers.
79
- */
80
- static async create(opts) {
81
- if (!isInitialized('chacha20'))
82
- throw new Error('leviathan-crypto: call init([\'chacha20\']) before using XChaCha20Poly1305Pool');
83
- const n = opts?.workers ?? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined) ?? 4;
84
- const mod = await getWasmModule();
85
- // Sequential spawn — compatible with inline-worker test environments
86
- const workers = [];
87
- for (let i = 0; i < n; i++)
88
- workers.push(await spawnWorker(mod));
89
- return new XChaCha20Poly1305Pool(workers);
90
- }
91
- /**
92
- * Encrypt plaintext with XChaCha20-Poly1305.
93
- * Returns `ciphertext || tag` (plaintext.length + 16 bytes).
94
- *
95
- * **Warning:** All input buffers are transferred and neutered after dispatch.
96
- */
97
- encrypt(key, nonce, plaintext, aad = new Uint8Array(0)) {
98
- if (this._disposed)
99
- return Promise.reject(new Error('leviathan-crypto: pool is disposed'));
100
- if (key.length !== 32)
101
- return Promise.reject(new RangeError(`key must be 32 bytes (got ${key.length})`));
102
- if (nonce.length !== 24)
103
- return Promise.reject(new RangeError(`XChaCha20 nonce must be 24 bytes (got ${nonce.length})`));
104
- return this._dispatch('encrypt', key, nonce, plaintext, aad);
105
- }
106
- /**
107
- * Decrypt ciphertext with XChaCha20-Poly1305.
108
- * Input is `ciphertext || tag` (at least 16 bytes).
109
- *
110
- * **Warning:** All input buffers are transferred and neutered after dispatch.
111
- */
112
- decrypt(key, nonce, ciphertext, aad = new Uint8Array(0)) {
113
- if (this._disposed)
114
- return Promise.reject(new Error('leviathan-crypto: pool is disposed'));
115
- if (key.length !== 32)
116
- return Promise.reject(new RangeError(`key must be 32 bytes (got ${key.length})`));
117
- if (nonce.length !== 24)
118
- return Promise.reject(new RangeError(`XChaCha20 nonce must be 24 bytes (got ${nonce.length})`));
119
- if (ciphertext.length < 16)
120
- return Promise.reject(new RangeError(`ciphertext too short — must include 16-byte tag (got ${ciphertext.length})`));
121
- return this._dispatch('decrypt', key, nonce, ciphertext, aad);
122
- }
123
- /** Terminates all workers. Rejects all pending and queued jobs. */
124
- dispose() {
125
- if (this._disposed)
126
- return;
127
- this._disposed = true;
128
- for (const w of this._workers)
129
- w.terminate();
130
- const err = new Error('leviathan-crypto: pool disposed');
131
- for (const { reject } of this._pending.values())
132
- reject(err);
133
- for (const job of this._queue)
134
- this._pending.get(job.id)?.reject(err);
135
- this._pending.clear();
136
- this._queue.length = 0;
137
- }
138
- /** Number of workers in the pool. */
139
- get size() {
140
- return this._workers.length;
141
- }
142
- /** Number of jobs currently queued (waiting for a free worker). */
143
- get queueDepth() {
144
- return this._queue.length;
145
- }
146
- // ── Internals ────────────────────────────────────────────────────────────
147
- _dispatch(op, key, nonce, data, aad) {
148
- return new Promise((resolve, reject) => {
149
- const id = this._nextId++;
150
- const job = { id, op, key, nonce, data, aad };
151
- this._pending.set(id, { resolve, reject });
152
- const worker = this._idle.pop();
153
- if (worker)
154
- this._send(worker, job);
155
- else
156
- this._queue.push(job);
157
- });
158
- }
159
- _send(worker, job) {
160
- worker.postMessage({ type: 'job', ...job }, [job.key.buffer, job.nonce.buffer, job.data.buffer, job.aad.buffer]);
161
- }
162
- _onMessage(worker, e) {
163
- const msg = e.data;
164
- const job = this._pending.get(msg.id);
165
- if (!job)
166
- return;
167
- this._pending.delete(msg.id);
168
- if (msg.type === 'result')
169
- job.resolve(msg.data);
170
- else
171
- job.reject(new Error(msg.message));
172
- const next = this._queue.shift();
173
- if (next)
174
- this._send(worker, next);
175
- else
176
- this._idle.push(worker);
177
- }
178
- }
@@ -1,37 +0,0 @@
1
- // / <reference lib="webworker" />
2
- // src/ts/chacha20/pool.worker.ts
3
- //
4
- // Worker entry point for XChaCha20Poly1305Pool. Runs in a Web Worker or
5
- // worker_threads context — no access to the main thread's module cache.
6
- // Owns its own WebAssembly.Instance with its own linear memory.
7
- import { xcEncrypt, xcDecrypt } from './ops.js';
8
- let x;
9
- self.onmessage = async (e) => {
10
- const msg = e.data;
11
- if (msg.type === 'init') {
12
- try {
13
- const mem = new WebAssembly.Memory({ initial: 3, maximum: 3 });
14
- const inst = await WebAssembly.instantiate(msg.module, { env: { memory: mem } });
15
- x = inst.exports;
16
- self.postMessage({ type: 'ready' });
17
- }
18
- catch (err) {
19
- self.postMessage({ type: 'error', id: -1, message: err.message });
20
- }
21
- return;
22
- }
23
- if (!x) {
24
- self.postMessage({ type: 'error', id: msg.id, message: 'worker not initialized' });
25
- return;
26
- }
27
- try {
28
- const { id, op, key, nonce, data, aad } = msg;
29
- const result = op === 'encrypt'
30
- ? xcEncrypt(x, key, nonce, data, aad)
31
- : xcDecrypt(x, key, nonce, data, aad);
32
- self.postMessage({ type: 'result', id, data: result }, [result.buffer]);
33
- }
34
- catch (err) {
35
- self.postMessage({ type: 'error', id: msg.id, message: err.message });
36
- }
37
- };
@@ -1,49 +0,0 @@
1
- export declare class XChaCha20StreamSealer {
2
- private readonly _x;
3
- private readonly _key;
4
- private readonly _cs;
5
- private readonly _id;
6
- private readonly _framed;
7
- private readonly _aad;
8
- private _index;
9
- private _state;
10
- /** Public: consumers use this 3-param form. */
11
- constructor(key: Uint8Array, chunkSize?: number, opts?: {
12
- framed?: boolean;
13
- aad?: Uint8Array;
14
- });
15
- /** @internal Test-only overload to inject fixed stream_id for deterministic output. */
16
- constructor(key: Uint8Array, chunkSize: number | undefined, opts: {
17
- framed?: boolean;
18
- aad?: Uint8Array;
19
- } | undefined, _id: Uint8Array);
20
- header(): Uint8Array;
21
- seal(plaintext: Uint8Array): Uint8Array;
22
- final(plaintext: Uint8Array): Uint8Array;
23
- private _sealChunk;
24
- private _wipe;
25
- dispose(): void;
26
- }
27
- export declare class XChaCha20StreamOpener {
28
- private readonly _x;
29
- private readonly _key;
30
- private readonly _cs;
31
- private readonly _id;
32
- private readonly _framed;
33
- private readonly _aad;
34
- private readonly _buf;
35
- private readonly _maxFrame;
36
- private _bufLen;
37
- private _index;
38
- private _dead;
39
- constructor(key: Uint8Array, header: Uint8Array, opts?: {
40
- framed?: boolean;
41
- aad?: Uint8Array;
42
- });
43
- get closed(): boolean;
44
- open(chunk: Uint8Array): Uint8Array;
45
- private _openRaw;
46
- feed(bytes: Uint8Array): Uint8Array[];
47
- private _wipe;
48
- dispose(): void;
49
- }
@@ -1,327 +0,0 @@
1
- // ▄▄▄▄▄▄▄▄▄▄
2
- // ▄████████████████████▄▄ ▒ ▄▀▀ ▒ ▒ █ ▄▀▄ ▀█▀ █ ▒ ▄▀▄ █▀▄
3
- // ▄██████████████████████ ▀████▄ ▓ ▓▀ ▓ ▓ ▓ ▓▄▓ ▓ ▓▀▓ ▓▄▓ ▓ ▓
4
- // ▄█████████▀▀▀ ▀███████▄▄███████▌ ▀▄ ▀▄▄ ▀▄▀ ▒ ▒ ▒ ▒ ▒ █ ▒ ▒ ▒ █
5
- // ▐████████▀ ▄▄▄▄ ▀████████▀██▀█▌
6
- // ████████ ███▀▀ ████▀ █▀ █▀ Leviathan Crypto Library
7
- // ███████▌ ▀██▀ ███
8
- // ███████ ▀███ ▀██ ▀█▄ Repository & Mirror:
9
- // ▀██████ ▄▄██ ▀▀ ██▄ github.com/xero/leviathan-crypto
10
- // ▀█████▄ ▄██▄ ▄▀▄▀ unpkg.com/leviathan-crypto
11
- // ▀████▄ ▄██▄
12
- // ▐████ ▐███ Author: xero (https://x-e.ro)
13
- // ▄▄██████████ ▐███ ▄▄ License: MIT
14
- // ▄██▀▀▀▀▀▀▀▀▀▀ ▄████ ▄██▀
15
- // ▄▀ ▄▄█████████▄▄ ▀▀▀▀▀ ▄███ This file is provided completely
16
- // ▄██████▀▀▀▀▀▀██████▄ ▀▄▄▄▄████▀ free, "as is", and without
17
- // ████▀ ▄▄▄▄▄▄▄ ▀████▄ ▀█████▀ ▄▄▄▄ warranty of any kind. The author
18
- // █████▄▄█████▀▀▀▀▀▀▄ ▀███▄ ▄████ assumes absolutely no liability
19
- // ▀██████▀ ▀████▄▄▄████▀ for its {ab,mis,}use.
20
- // ▀█████▀▀
21
- //
22
- // src/ts/chacha20/stream-sealer.ts
23
- //
24
- // XChaCha20Stream — chunked authenticated encryption for large payloads.
25
- // Tier 2 pure-TS: XChaCha20-Poly1305 per chunk. Simpler than SerpentStream —
26
- // no HKDF, no HMAC; Poly1305 handles auth. Stream binding via per-chunk AAD.
27
- // Wire format: header(20) | chunks: isLast(1) || nonce(24) || ct || tag(16)
28
- import { getInstance } from '../init.js';
29
- import { xcEncrypt, xcDecrypt } from './ops.js';
30
- import { randomBytes, wipe } from '../utils.js';
31
- import { _chachaReady } from './index.js';
32
- const CHUNK_MIN = 1024;
33
- const CHUNK_MAX = 65536;
34
- const CHUNK_DEF = 65536;
35
- function u32be(n) {
36
- const b = new Uint8Array(4);
37
- b[0] = (n >>> 24) & 0xff;
38
- b[1] = (n >>> 16) & 0xff;
39
- b[2] = (n >>> 8) & 0xff;
40
- b[3] = n & 0xff;
41
- return b;
42
- }
43
- function u64be(n) {
44
- const b = new Uint8Array(8);
45
- const hi = Math.floor(n / 0x100000000);
46
- const lo = n >>> 0;
47
- b[0] = (hi >>> 24) & 0xff;
48
- b[1] = (hi >>> 16) & 0xff;
49
- b[2] = (hi >>> 8) & 0xff;
50
- b[3] = hi & 0xff;
51
- b[4] = (lo >>> 24) & 0xff;
52
- b[5] = (lo >>> 16) & 0xff;
53
- b[6] = (lo >>> 8) & 0xff;
54
- b[7] = lo & 0xff;
55
- return b;
56
- }
57
- function getExports() {
58
- return getInstance('chacha20').exports;
59
- }
60
- /** Build internal AAD: stream_id(16) || u64be(index) || isLast(1) || u32be(userAad.length) || userAad */
61
- function chunkAAD(streamId, index, isLast, userAad) {
62
- const out = new Uint8Array(16 + 8 + 1 + 4 + userAad.length);
63
- let off = 0;
64
- out.set(streamId, off);
65
- off += 16;
66
- out.set(u64be(index), off);
67
- off += 8;
68
- out[off++] = isLast ? 1 : 0;
69
- out.set(u32be(userAad.length), off);
70
- off += 4;
71
- out.set(userAad, off);
72
- return out;
73
- }
74
- export class XChaCha20StreamSealer {
75
- _x;
76
- _key;
77
- _cs;
78
- _id; // stream_id (16 bytes)
79
- _framed;
80
- _aad;
81
- _index;
82
- _state;
83
- constructor(key, chunkSize, opts, _id) {
84
- if (!_chachaReady())
85
- throw new Error('leviathan-crypto: call init([\'chacha20\']) before using XChaCha20StreamSealer');
86
- if (key.length !== 32)
87
- throw new RangeError(`XChaCha20StreamSealer key must be 32 bytes (got ${key.length})`);
88
- const cs = chunkSize ?? CHUNK_DEF;
89
- if (cs < CHUNK_MIN || cs > CHUNK_MAX)
90
- throw new RangeError(`XChaCha20StreamSealer chunkSize must be ${CHUNK_MIN}..${CHUNK_MAX} (got ${cs})`);
91
- this._x = getExports();
92
- this._key = key.slice();
93
- this._cs = cs;
94
- this._framed = opts?.framed ?? false;
95
- this._aad = opts?.aad ? opts.aad.slice() : new Uint8Array(0);
96
- this._id = new Uint8Array(16);
97
- if (_id && _id.length === 16)
98
- this._id.set(_id);
99
- else
100
- crypto.getRandomValues(this._id);
101
- this._index = 0;
102
- this._state = 'fresh';
103
- }
104
- header() {
105
- if (this._state === 'sealing')
106
- throw new Error('XChaCha20StreamSealer: header() already called');
107
- if (this._state === 'dead')
108
- throw new Error('XChaCha20StreamSealer: stream is closed');
109
- this._state = 'sealing';
110
- const hdr = new Uint8Array(20);
111
- hdr.set(this._id, 0);
112
- hdr.set(u32be(this._cs), 16);
113
- return hdr;
114
- }
115
- seal(plaintext) {
116
- if (this._state === 'fresh')
117
- throw new Error('XChaCha20StreamSealer: call header() first');
118
- if (this._state === 'dead')
119
- throw new Error('XChaCha20StreamSealer: stream is closed');
120
- if (plaintext.length !== this._cs)
121
- throw new RangeError(`XChaCha20StreamSealer: seal() requires exactly ${this._cs} bytes (got ${plaintext.length})`);
122
- return this._sealChunk(plaintext, false);
123
- }
124
- final(plaintext) {
125
- if (this._state === 'fresh')
126
- throw new Error('XChaCha20StreamSealer: call header() first');
127
- if (this._state === 'dead')
128
- throw new Error('XChaCha20StreamSealer: stream is closed');
129
- if (plaintext.length > this._cs)
130
- throw new RangeError(`XChaCha20StreamSealer: final() plaintext exceeds chunkSize (got ${plaintext.length})`);
131
- const out = this._sealChunk(plaintext, true);
132
- this._wipe();
133
- return out;
134
- }
135
- _sealChunk(plaintext, isLast) {
136
- const nonce = randomBytes(24);
137
- const aad = chunkAAD(this._id, this._index, isLast, this._aad);
138
- const sealed = xcEncrypt(this._x, this._key, nonce, plaintext, aad);
139
- this._index++;
140
- // sealed = ciphertext || tag(16)
141
- // wire chunk = isLast(1) || nonce(24) || sealed
142
- const chunk = new Uint8Array(1 + 24 + sealed.length);
143
- chunk[0] = isLast ? 1 : 0;
144
- chunk.set(nonce, 1);
145
- chunk.set(sealed, 25);
146
- if (!this._framed)
147
- return chunk;
148
- const out = new Uint8Array(4 + chunk.length);
149
- out.set(u32be(chunk.length), 0);
150
- out.set(chunk, 4);
151
- return out;
152
- }
153
- _wipe() {
154
- wipe(this._key);
155
- wipe(this._id);
156
- wipe(this._aad);
157
- this._x.wipeBuffers();
158
- this._state = 'dead';
159
- }
160
- dispose() {
161
- if (this._state !== 'dead')
162
- this._wipe();
163
- }
164
- }
165
- export class XChaCha20StreamOpener {
166
- _x;
167
- _key;
168
- _cs;
169
- _id; // stream_id from header
170
- _framed;
171
- _aad;
172
- _buf;
173
- _maxFrame;
174
- _bufLen;
175
- _index;
176
- _dead;
177
- constructor(key, header, opts) {
178
- if (!_chachaReady())
179
- throw new Error('leviathan-crypto: call init([\'chacha20\']) before using XChaCha20StreamOpener');
180
- if (key.length !== 32)
181
- throw new RangeError(`XChaCha20StreamOpener key must be 32 bytes (got ${key.length})`);
182
- if (header.length !== 20)
183
- throw new RangeError(`XChaCha20StreamOpener header must be 20 bytes (got ${header.length})`);
184
- this._x = getExports();
185
- this._key = key.slice();
186
- this._id = header.slice(0, 16);
187
- this._cs = (header[16] << 24 | header[17] << 16 | header[18] << 8 | header[19]) >>> 0;
188
- if (this._cs < CHUNK_MIN || this._cs > CHUNK_MAX)
189
- throw new RangeError(`XChaCha20StreamOpener: header contains invalid chunkSize ${this._cs} (expected ${CHUNK_MIN}..${CHUNK_MAX})`);
190
- this._framed = opts?.framed ?? false;
191
- this._aad = opts?.aad ? opts.aad.slice() : new Uint8Array(0);
192
- this._index = 0;
193
- this._dead = false;
194
- this._bufLen = 0;
195
- if (this._framed) {
196
- // max sealed chunk: 1 + 24 + CHUNK_MAX + 16
197
- this._maxFrame = 4 + 1 + 24 + CHUNK_MAX + 16;
198
- this._buf = new Uint8Array(this._maxFrame);
199
- }
200
- }
201
- get closed() {
202
- return this._dead;
203
- }
204
- open(chunk) {
205
- if (this._dead)
206
- throw new Error('XChaCha20StreamOpener: stream is closed');
207
- if (this._framed)
208
- throw new Error('XChaCha20StreamOpener: call feed() on framed openers — open() expects raw sealed chunks without length prefix');
209
- return this._openRaw(chunk);
210
- }
211
- _openRaw(chunk) {
212
- // isLast(1) || nonce(24) || ciphertext || tag(16)
213
- if (chunk.length < 1 + 24 + 16)
214
- throw new RangeError('XChaCha20StreamOpener: chunk too short');
215
- const isLast = chunk[0] !== 0;
216
- const nonce = chunk.subarray(1, 25);
217
- const payload = chunk.subarray(25); // ciphertext || tag(16)
218
- if (payload.length < 16)
219
- throw new RangeError('XChaCha20StreamOpener: chunk too short for tag');
220
- const aad = chunkAAD(this._id, this._index, isLast, this._aad);
221
- // xcDecrypt expects ciphertext || tag combined
222
- const plaintext = xcDecrypt(this._x, this._key, nonce, payload, aad);
223
- this._index++;
224
- if (isLast)
225
- this._wipe();
226
- return plaintext;
227
- }
228
- feed(bytes) {
229
- if (!this._framed)
230
- throw new Error('XChaCha20StreamOpener: feed() requires { framed: true }');
231
- if (this._dead)
232
- throw new Error('XChaCha20StreamOpener: stream is closed');
233
- const buf = this._buf;
234
- const maxFrame = this._maxFrame;
235
- const results = [];
236
- let consumed = 0;
237
- // ── Phase 1: drain carry-over ─────────────────────────────────────
238
- if (this._bufLen > 0) {
239
- if (this._bufLen < 4) {
240
- const need = 4 - this._bufLen;
241
- const take = Math.min(need, bytes.length - consumed);
242
- buf.set(bytes.subarray(consumed, consumed + take), this._bufLen);
243
- this._bufLen += take;
244
- consumed += take;
245
- if (this._bufLen < 4)
246
- return results;
247
- }
248
- const sealedLen = (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
249
- if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
250
- this._wipe();
251
- throw new Error('XChaCha20StreamOpener: invalid sealed chunk length');
252
- }
253
- const frameLen = 4 + sealedLen;
254
- const haveInBuf = this._bufLen;
255
- const needMore = frameLen - haveInBuf;
256
- if (needMore > 0) {
257
- const take = Math.min(needMore, bytes.length - consumed);
258
- buf.set(bytes.subarray(consumed, consumed + take), haveInBuf);
259
- this._bufLen += take;
260
- consumed += take;
261
- if (this._bufLen < frameLen)
262
- return results;
263
- }
264
- results.push(this._openRaw(buf.subarray(4, frameLen)));
265
- this._bufLen = 0;
266
- if (this._dead) {
267
- if (consumed < bytes.length) {
268
- this._wipe();
269
- throw new Error('XChaCha20StreamOpener: unexpected bytes after final chunk');
270
- }
271
- return results;
272
- }
273
- }
274
- // ── Phase 2: parse complete frames directly from bytes ────────────
275
- let pos = consumed;
276
- while (true) {
277
- if (bytes.length - pos < 4)
278
- break;
279
- const sealedLen = (bytes[pos] << 24 | bytes[pos + 1] << 16 | bytes[pos + 2] << 8 | bytes[pos + 3]) >>> 0;
280
- if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
281
- this._wipe();
282
- throw new Error('XChaCha20StreamOpener: invalid sealed chunk length');
283
- }
284
- const frameLen = 4 + sealedLen;
285
- if (bytes.length - pos < frameLen)
286
- break;
287
- results.push(this._openRaw(bytes.subarray(pos + 4, pos + frameLen)));
288
- if (this._dead) {
289
- const remaining = bytes.length - pos - frameLen;
290
- if (remaining > 0) {
291
- this._wipe();
292
- throw new Error('XChaCha20StreamOpener: unexpected bytes after final chunk');
293
- }
294
- return results;
295
- }
296
- pos += frameLen;
297
- }
298
- // ── Carry over incomplete trailing bytes ──────────────────────────
299
- const leftover = bytes.length - pos;
300
- if (leftover > 0) {
301
- if (leftover > maxFrame) {
302
- this._wipe();
303
- throw new Error('XChaCha20StreamOpener: input exceeds maximum frame size');
304
- }
305
- buf.set(bytes.subarray(pos), 0);
306
- this._bufLen = leftover;
307
- }
308
- return results;
309
- }
310
- _wipe() {
311
- if (this._dead)
312
- return;
313
- wipe(this._key);
314
- wipe(this._id);
315
- wipe(this._aad);
316
- this._x.wipeBuffers();
317
- if (this._framed) {
318
- wipe(this._buf);
319
- this._bufLen = 0;
320
- }
321
- this._dead = true;
322
- }
323
- dispose() {
324
- if (!this._dead)
325
- this._wipe();
326
- }
327
- }