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,275 +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/serpent/stream-pool.ts
23
- //
24
- // SerpentStreamPool — parallel worker pool for SerpentStream.
25
- // Dispatches chunk-level seal/open jobs across Web Workers, each
26
- // with its own serpent.wasm and sha2.wasm instances.
27
- import { isInitialized } from '../init.js';
28
- import { HKDF_SHA256 } from '../sha2/index.js';
29
- import { decodeWasm } from '../loader.js';
30
- import { u32be, u64be, deriveChunkKeys } from './stream.js';
31
- // ── Constants ─────────────────────────────────────────────────────────────────
32
- const CHUNK_MIN = 1024;
33
- const CHUNK_MAX = 65536;
34
- const CHUNK_DEF = 65536;
35
- // ── WASM module singletons ──────────────────────────────────────────────────
36
- let _serpentModule;
37
- let _sha2Module;
38
- async function getSerpentModule() {
39
- if (_serpentModule)
40
- return _serpentModule;
41
- const { WASM_GZ_BASE64 } = await import('../embedded/serpent.js');
42
- const bytes = await decodeWasm(WASM_GZ_BASE64);
43
- _serpentModule = await WebAssembly.compile(bytes.buffer);
44
- return _serpentModule;
45
- }
46
- async function getSha2Module() {
47
- if (_sha2Module)
48
- return _sha2Module;
49
- const { WASM_GZ_BASE64 } = await import('../embedded/sha2.js');
50
- const bytes = await decodeWasm(WASM_GZ_BASE64);
51
- _sha2Module = await WebAssembly.compile(bytes.buffer);
52
- return _sha2Module;
53
- }
54
- // ── Worker spawning ──────────────────────────────────────────────────────────
55
- function spawnWorker(serpentMod, sha2Mod) {
56
- return new Promise((resolve, reject) => {
57
- const worker = new Worker(new URL('./stream.worker.js', import.meta.url), { type: 'module' });
58
- const onMessage = (e) => {
59
- cleanup();
60
- if (e.data.type === 'ready') {
61
- resolve(worker);
62
- }
63
- else {
64
- worker.terminate();
65
- reject(new Error(`leviathan-crypto: worker init failed: ${e.data.message}`));
66
- }
67
- };
68
- const onError = (e) => {
69
- cleanup();
70
- worker.terminate();
71
- reject(new Error(`leviathan-crypto: worker init failed: ${e.message}`));
72
- };
73
- const cleanup = () => {
74
- worker.removeEventListener('message', onMessage);
75
- worker.removeEventListener('error', onError);
76
- };
77
- worker.addEventListener('message', onMessage);
78
- worker.addEventListener('error', onError);
79
- worker.postMessage({ type: 'init', serpentModule: serpentMod, sha2Module: sha2Mod });
80
- });
81
- }
82
- // ── Pool class ───────────────────────────────────────────────────────────────
83
- /**
84
- * Parallel worker pool for SerpentStream chunked authenticated encryption.
85
- *
86
- * Each worker owns its own `serpent.wasm` and `sha2.wasm` instances with
87
- * isolated linear memory. Key derivation happens on the main thread; workers
88
- * receive pre-derived encKey/macKey per chunk.
89
- *
90
- * Produces the same wire format as `SerpentStream` -- either can decrypt
91
- * the other's output.
92
- */
93
- export class SerpentStreamPool {
94
- _workers;
95
- _idle;
96
- _queue;
97
- _pending;
98
- _hkdf;
99
- _nextId;
100
- _disposed;
101
- constructor(workers, hkdf) {
102
- this._workers = workers;
103
- this._idle = [...workers];
104
- this._queue = [];
105
- this._pending = new Map();
106
- this._hkdf = hkdf;
107
- this._nextId = 0;
108
- this._disposed = false;
109
- for (const w of workers) {
110
- w.onmessage = (e) => this._onMessage(w, e);
111
- }
112
- }
113
- /**
114
- * Create a new pool. Requires `init(['serpent', 'sha2'])` to have been called.
115
- * Compiles both WASM modules once and distributes them to all workers.
116
- */
117
- static async create(opts) {
118
- if (!isInitialized('serpent') || !isInitialized('sha2'))
119
- throw new Error('leviathan-crypto: call init([\'serpent\', \'sha2\']) before using SerpentStreamPool');
120
- const n = opts?.workers ?? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined) ?? 4;
121
- const [serpentMod, sha2Mod] = await Promise.all([getSerpentModule(), getSha2Module()]);
122
- // Sequential spawn — compatible with inline-worker test environments
123
- const workers = [];
124
- for (let i = 0; i < n; i++)
125
- workers.push(await spawnWorker(serpentMod, sha2Mod));
126
- const hkdf = new HKDF_SHA256();
127
- return new SerpentStreamPool(workers, hkdf);
128
- }
129
- /**
130
- * Encrypt plaintext with SerpentStream chunked authenticated encryption.
131
- * Returns the complete wire format (header + encrypted chunks).
132
- */
133
- async seal(key, plaintext, chunkSize) {
134
- if (this._disposed)
135
- throw new Error('leviathan-crypto: pool is disposed');
136
- if (key.length !== 32)
137
- throw new RangeError(`SerpentStream key must be 32 bytes (got ${key.length})`);
138
- const cs = chunkSize ?? CHUNK_DEF;
139
- if (cs < CHUNK_MIN || cs > CHUNK_MAX)
140
- throw new RangeError(`SerpentStream chunkSize must be ${CHUNK_MIN}..${CHUNK_MAX} (got ${cs})`);
141
- const streamNonce = new Uint8Array(16);
142
- crypto.getRandomValues(streamNonce);
143
- const chunkCount = plaintext.length === 0 ? 1 : Math.ceil(plaintext.length / cs);
144
- // Dispatch all chunk jobs in parallel
145
- const chunkPromises = [];
146
- for (let i = 0; i < chunkCount; i++) {
147
- const start = i * cs;
148
- const end = Math.min(start + cs, plaintext.length);
149
- const slice = plaintext.slice(start, end);
150
- const isLast = i === chunkCount - 1;
151
- const { encKey, macKey } = deriveChunkKeys(this._hkdf, key, streamNonce, cs, chunkCount, i, isLast);
152
- chunkPromises.push(this._dispatch('seal', encKey, macKey, slice));
153
- }
154
- const chunkResults = await Promise.all(chunkPromises);
155
- // Compute total output size
156
- let totalWire = 28;
157
- for (const c of chunkResults)
158
- totalWire += c.length;
159
- const out = new Uint8Array(totalWire);
160
- // Write header
161
- out.set(streamNonce, 0);
162
- out.set(u32be(cs), 16);
163
- out.set(u64be(chunkCount), 20);
164
- let pos = 28;
165
- for (const c of chunkResults) {
166
- out.set(c, pos);
167
- pos += c.length;
168
- }
169
- return out;
170
- }
171
- /**
172
- * Decrypt SerpentStream wire format.
173
- * If any chunk fails authentication, rejects immediately -- no partial plaintext.
174
- */
175
- async open(key, ciphertext) {
176
- if (this._disposed)
177
- throw new Error('leviathan-crypto: pool is disposed');
178
- if (key.length !== 32)
179
- throw new RangeError(`SerpentStream key must be 32 bytes (got ${key.length})`);
180
- if (ciphertext.length < 28 + 32)
181
- throw new RangeError('SerpentStream: ciphertext too short');
182
- // Parse header
183
- const streamNonce = ciphertext.subarray(0, 16);
184
- const csView = ciphertext.subarray(16, 20);
185
- const cs = (csView[0] << 24) | (csView[1] << 16) | (csView[2] << 8) | csView[3];
186
- const ccView = ciphertext.subarray(20, 28);
187
- let chunkCount = 0;
188
- for (let i = 0; i < 8; i++)
189
- chunkCount = chunkCount * 256 + ccView[i];
190
- // Dispatch all chunk jobs
191
- const chunkPromises = [];
192
- let pos = 28;
193
- for (let i = 0; i < chunkCount; i++) {
194
- const isLast = i === chunkCount - 1;
195
- const wireLen = isLast ? ciphertext.length - pos : cs + 32;
196
- const wireSlice = ciphertext.slice(pos, pos + wireLen);
197
- const { encKey, macKey } = deriveChunkKeys(this._hkdf, key, streamNonce, cs, chunkCount, i, isLast);
198
- chunkPromises.push(this._dispatch('open', encKey, macKey, wireSlice));
199
- pos += wireLen;
200
- }
201
- // Await all — Promise.all rejects on first failure
202
- const results = await Promise.all(chunkPromises);
203
- // Reassemble plaintext
204
- let totalPt = 0;
205
- for (const r of results)
206
- totalPt += r.length;
207
- const plaintext = new Uint8Array(totalPt);
208
- let ptPos = 0;
209
- for (const r of results) {
210
- plaintext.set(r, ptPos);
211
- ptPos += r.length;
212
- }
213
- return plaintext;
214
- }
215
- /** Terminates all workers. Rejects all pending and queued jobs. */
216
- dispose() {
217
- if (this._disposed)
218
- return;
219
- this._disposed = true;
220
- for (const w of this._workers)
221
- w.terminate();
222
- const err = new Error('leviathan-crypto: pool disposed');
223
- for (const { reject } of this._pending.values())
224
- reject(err);
225
- for (const job of this._queue)
226
- this._pending.get(job.id)?.reject(err);
227
- this._pending.clear();
228
- this._queue.length = 0;
229
- this._hkdf.dispose();
230
- }
231
- /** Number of workers in the pool. */
232
- get size() {
233
- return this._workers.length;
234
- }
235
- /** Number of jobs currently queued (waiting for a free worker). */
236
- get queueDepth() {
237
- return this._queue.length;
238
- }
239
- // ── Internals ────────────────────────────────────────────────────────────
240
- _dispatch(op, encKey, macKey, data) {
241
- return new Promise((resolve, reject) => {
242
- const id = this._nextId++;
243
- const job = { id, op, encKey, macKey, data };
244
- this._pending.set(id, { resolve, reject });
245
- const worker = this._idle.pop();
246
- if (worker)
247
- this._send(worker, job);
248
- else
249
- this._queue.push(job);
250
- });
251
- }
252
- _send(worker, job) {
253
- // No transfer list: @vitest/web-worker (same-thread fake worker used in
254
- // Vitest test environment) silently drops postMessage calls that include a
255
- // non-empty Transferable array. encKey/macKey/data are already .slice()
256
- // copies — neutering was never part of SerpentStreamPool's public contract.
257
- worker.postMessage({ type: 'job', ...job });
258
- }
259
- _onMessage(worker, e) {
260
- const msg = e.data;
261
- const job = this._pending.get(msg.id);
262
- if (!job)
263
- return;
264
- this._pending.delete(msg.id);
265
- if (msg.type === 'result')
266
- job.resolve(msg.data);
267
- else
268
- job.reject(new Error(msg.message));
269
- const next = this._queue.shift();
270
- if (next)
271
- this._send(worker, next);
272
- else
273
- this._idle.push(worker);
274
- }
275
- }
@@ -1,55 +0,0 @@
1
- export declare class SerpentStreamSealer {
2
- private readonly _key;
3
- private readonly _cs;
4
- private readonly _nonce;
5
- private readonly _cbc;
6
- private readonly _hmac;
7
- private readonly _hkdf;
8
- private readonly _framed;
9
- private readonly _aad;
10
- private readonly _ivs;
11
- private _ivIdx;
12
- private _index;
13
- private _state;
14
- /** Public: consumers use this 3-param form. */
15
- constructor(key: Uint8Array, chunkSize?: number, opts?: {
16
- framed?: boolean;
17
- aad?: Uint8Array;
18
- });
19
- /** @internal Test-only overload to inject fixed nonce/IVs for deterministic KAT vectors. */
20
- constructor(key: Uint8Array, chunkSize: number | undefined, opts: {
21
- framed?: boolean;
22
- aad?: Uint8Array;
23
- } | undefined, _nonce: Uint8Array, _ivs?: Uint8Array[]);
24
- header(): Uint8Array;
25
- seal(plaintext: Uint8Array): Uint8Array;
26
- final(plaintext: Uint8Array): Uint8Array;
27
- private _sealChunk;
28
- private _wipe;
29
- dispose(): void;
30
- }
31
- export declare class SerpentStreamOpener {
32
- private readonly _key;
33
- private readonly _cs;
34
- private readonly _nonce;
35
- private readonly _cbc;
36
- private readonly _hmac;
37
- private readonly _hkdf;
38
- private readonly _framed;
39
- private readonly _aad;
40
- private readonly _buf;
41
- private readonly _maxFrame;
42
- private _bufLen;
43
- private _index;
44
- private _dead;
45
- constructor(key: Uint8Array, header: Uint8Array, opts?: {
46
- framed?: boolean;
47
- aad?: Uint8Array;
48
- });
49
- get closed(): boolean;
50
- open(chunk: Uint8Array): Uint8Array;
51
- private _openRaw;
52
- feed(bytes: Uint8Array): Uint8Array[];
53
- private _wipe;
54
- dispose(): void;
55
- }
@@ -1,342 +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/serpent/stream-sealer.ts
23
- //
24
- // Tier 2 pure-TS composition: SerpentCbc + HMAC_SHA256 + HKDF_SHA256.
25
- // Incremental streaming AEAD — seal/open one chunk at a time.
26
- // Wire format: header (20) | chunks: IV(16) || ct(padded) || HMAC(32)
27
- import { SerpentCbc, _serpentReady } from './index.js';
28
- import { HMAC_SHA256, HKDF_SHA256, _sha2Ready } from '../sha2/index.js';
29
- import { concat, constantTimeEqual, wipe } from '../utils.js';
30
- import { u32be, u64be } from './stream.js';
31
- const DOMAIN = 'serpent-sealstream-v1';
32
- const DOMAIN_BYTES = new TextEncoder().encode(DOMAIN); // 21 bytes
33
- const CHUNK_MIN = 1024;
34
- const CHUNK_MAX = 65536;
35
- const CHUNK_DEF = 65536;
36
- function chunkInfo(streamNonce, chunkSize, index, isLast) {
37
- // 21 + 16 + 4 + 8 + 1 = 50 bytes
38
- const info = new Uint8Array(50);
39
- let off = 0;
40
- info.set(DOMAIN_BYTES, off);
41
- off += 21;
42
- info.set(streamNonce, off);
43
- off += 16;
44
- info.set(u32be(chunkSize), off);
45
- off += 4;
46
- info.set(u64be(index), off);
47
- off += 8;
48
- info[off] = isLast ? 0x01 : 0x00;
49
- return info;
50
- }
51
- function deriveChunkKeys(hkdf, key, streamNonce, chunkSize, index, isLast) {
52
- const info = chunkInfo(streamNonce, chunkSize, index, isLast);
53
- const derived = hkdf.derive(key, new Uint8Array(0), info, 64);
54
- return {
55
- encKey: derived.subarray(0, 32),
56
- macKey: derived.subarray(32, 64),
57
- };
58
- }
59
- export class SerpentStreamSealer {
60
- _key;
61
- _cs; // chunk size
62
- _nonce; // stream nonce (16 bytes)
63
- _cbc;
64
- _hmac;
65
- _hkdf;
66
- _framed;
67
- _aad;
68
- _ivs; // test seam: fixed IVs
69
- _ivIdx;
70
- _index;
71
- _state;
72
- constructor(key, chunkSize, opts, _nonce, _ivs) {
73
- if (!_serpentReady())
74
- throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamSealer');
75
- if (!_sha2Ready())
76
- throw new Error('leviathan-crypto: call init([\'sha2\']) before using SerpentStreamSealer');
77
- if (key.length !== 64)
78
- throw new RangeError(`SerpentStreamSealer key must be 64 bytes (got ${key.length})`);
79
- const cs = chunkSize ?? CHUNK_DEF;
80
- if (cs < CHUNK_MIN || cs > CHUNK_MAX)
81
- throw new RangeError(`SerpentStreamSealer chunkSize must be ${CHUNK_MIN}..${CHUNK_MAX} (got ${cs})`);
82
- this._key = key.slice();
83
- this._cs = cs;
84
- this._framed = opts?.framed ?? false;
85
- this._aad = opts?.aad ? opts.aad.slice() : new Uint8Array(0);
86
- this._nonce = new Uint8Array(16);
87
- if (_nonce && _nonce.length === 16) {
88
- this._nonce.set(_nonce);
89
- }
90
- else {
91
- crypto.getRandomValues(this._nonce);
92
- }
93
- this._cbc = new SerpentCbc({ dangerUnauthenticated: true });
94
- this._hmac = new HMAC_SHA256();
95
- this._hkdf = new HKDF_SHA256();
96
- this._ivs = _ivs;
97
- this._ivIdx = 0;
98
- this._index = 0;
99
- this._state = 'fresh';
100
- }
101
- header() {
102
- if (this._state === 'sealing')
103
- throw new Error('SerpentStreamSealer: header() already called');
104
- if (this._state === 'dead')
105
- throw new Error('SerpentStreamSealer: stream is closed');
106
- this._state = 'sealing';
107
- const hdr = new Uint8Array(20);
108
- hdr.set(this._nonce, 0);
109
- hdr.set(u32be(this._cs), 16);
110
- return hdr;
111
- }
112
- seal(plaintext) {
113
- if (this._state === 'fresh')
114
- throw new Error('SerpentStreamSealer: call header() first');
115
- if (this._state === 'dead')
116
- throw new Error('SerpentStreamSealer: stream is closed');
117
- if (plaintext.length !== this._cs)
118
- throw new RangeError(`SerpentStreamSealer: seal() requires exactly ${this._cs} bytes (got ${plaintext.length})`);
119
- return this._sealChunk(plaintext, false);
120
- }
121
- final(plaintext) {
122
- if (this._state === 'fresh')
123
- throw new Error('SerpentStreamSealer: call header() first');
124
- if (this._state === 'dead')
125
- throw new Error('SerpentStreamSealer: stream is closed');
126
- if (plaintext.length > this._cs)
127
- throw new RangeError(`SerpentStreamSealer: final() plaintext exceeds chunkSize (got ${plaintext.length})`);
128
- const out = this._sealChunk(plaintext, true);
129
- this._wipe();
130
- return out;
131
- }
132
- _sealChunk(plaintext, isLast) {
133
- const { encKey, macKey } = deriveChunkKeys(this._hkdf, this._key, this._nonce, this._cs, this._index, isLast);
134
- const iv = new Uint8Array(16);
135
- if (this._ivs && this._ivIdx < this._ivs.length) {
136
- iv.set(this._ivs[this._ivIdx++]);
137
- }
138
- else {
139
- crypto.getRandomValues(iv);
140
- }
141
- const ciphertext = this._cbc.encrypt(encKey, iv, plaintext);
142
- const tag = this._hmac.hash(macKey, concat(u32be(this._aad.length), this._aad, iv, ciphertext));
143
- this._index++;
144
- const sealed = concat(new Uint8Array([isLast ? 1 : 0]), iv, ciphertext, tag);
145
- if (!this._framed)
146
- return sealed;
147
- const out = new Uint8Array(4 + sealed.length);
148
- out.set(u32be(sealed.length), 0);
149
- out.set(sealed, 4);
150
- return out;
151
- }
152
- _wipe() {
153
- wipe(this._key);
154
- wipe(this._nonce);
155
- wipe(this._aad);
156
- this._cbc.dispose();
157
- this._hmac.dispose();
158
- this._hkdf.dispose();
159
- this._state = 'dead';
160
- }
161
- dispose() {
162
- if (this._state !== 'dead')
163
- this._wipe();
164
- }
165
- }
166
- export class SerpentStreamOpener {
167
- _key;
168
- _cs;
169
- _nonce;
170
- _cbc;
171
- _hmac;
172
- _hkdf;
173
- _framed;
174
- _aad;
175
- _buf;
176
- _maxFrame;
177
- _bufLen;
178
- _index;
179
- _dead;
180
- constructor(key, header, opts) {
181
- if (!_serpentReady())
182
- throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamOpener');
183
- if (!_sha2Ready())
184
- throw new Error('leviathan-crypto: call init([\'sha2\']) before using SerpentStreamOpener');
185
- if (key.length !== 64)
186
- throw new RangeError(`SerpentStreamOpener key must be 64 bytes (got ${key.length})`);
187
- if (header.length !== 20)
188
- throw new RangeError(`SerpentStreamOpener header must be 20 bytes (got ${header.length})`);
189
- this._key = key.slice();
190
- this._nonce = header.slice(0, 16);
191
- this._cs = (header[16] << 24 | header[17] << 16 | header[18] << 8 | header[19]) >>> 0;
192
- if (this._cs < CHUNK_MIN || this._cs > CHUNK_MAX)
193
- throw new RangeError(`SerpentStreamOpener: header contains invalid chunkSize ${this._cs} (expected ${CHUNK_MIN}..${CHUNK_MAX})`);
194
- this._framed = opts?.framed ?? false;
195
- this._aad = opts?.aad ? opts.aad.slice() : new Uint8Array(0);
196
- this._cbc = new SerpentCbc({ dangerUnauthenticated: true });
197
- this._hmac = new HMAC_SHA256();
198
- this._hkdf = new HKDF_SHA256();
199
- this._index = 0;
200
- this._dead = false;
201
- this._bufLen = 0;
202
- if (this._framed) {
203
- const cs = this._cs;
204
- const maxSealed = 1 + 16 + (cs + (16 - (cs % 16))) + 32;
205
- this._maxFrame = 4 + maxSealed;
206
- this._buf = new Uint8Array(this._maxFrame);
207
- }
208
- }
209
- get closed() {
210
- return this._dead;
211
- }
212
- open(chunk) {
213
- if (this._dead)
214
- throw new Error('SerpentStreamOpener: stream is closed');
215
- if (this._framed)
216
- throw new Error('SerpentStreamOpener: call feed() on framed openers — open() expects raw sealed chunks without length prefix');
217
- return this._openRaw(chunk);
218
- }
219
- _openRaw(chunk) {
220
- // isLast(1) || IV(16) || ciphertext || HMAC(32)
221
- if (chunk.length < 1 + 16 + 16 + 32)
222
- throw new RangeError('SerpentStreamOpener: chunk wire data too short');
223
- const isLast = chunk[0] !== 0;
224
- const { encKey, macKey } = deriveChunkKeys(this._hkdf, this._key, this._nonce, this._cs, this._index, isLast);
225
- const iv = chunk.subarray(1, 17);
226
- const ciphertext = chunk.subarray(17, chunk.length - 32);
227
- const tag = chunk.subarray(chunk.length - 32);
228
- const expectedTag = this._hmac.hash(macKey, concat(u32be(this._aad.length), this._aad, iv, ciphertext));
229
- if (!constantTimeEqual(tag, expectedTag))
230
- throw new Error('SerpentStreamOpener: authentication failed');
231
- const plaintext = this._cbc.decrypt(encKey, iv, ciphertext);
232
- this._index++;
233
- if (isLast)
234
- this._wipe();
235
- return plaintext;
236
- }
237
- feed(bytes) {
238
- if (!this._framed)
239
- throw new Error('SerpentStreamOpener: feed() requires { framed: true }');
240
- if (this._dead)
241
- throw new Error('SerpentStreamOpener: stream is closed');
242
- const buf = this._buf;
243
- const maxFrame = this._maxFrame;
244
- const results = [];
245
- let consumed = 0;
246
- // ── Phase 1: drain carry-over ─────────────────────────────────────
247
- if (this._bufLen > 0) {
248
- // Sub-case A: partial prefix — we have < 4 bytes buffered
249
- if (this._bufLen < 4) {
250
- const need = 4 - this._bufLen;
251
- const take = Math.min(need, bytes.length - consumed);
252
- buf.set(bytes.subarray(consumed, consumed + take), this._bufLen);
253
- this._bufLen += take;
254
- consumed += take;
255
- if (this._bufLen < 4)
256
- return results;
257
- }
258
- const sealedLen = (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
259
- if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
260
- this._wipe();
261
- throw new Error('SerpentStreamOpener: invalid sealed chunk length');
262
- }
263
- const frameLen = 4 + sealedLen;
264
- // Sub-case B: partial frame body
265
- const haveInBuf = this._bufLen;
266
- const needMore = frameLen - haveInBuf;
267
- if (needMore > 0) {
268
- const take = Math.min(needMore, bytes.length - consumed);
269
- buf.set(bytes.subarray(consumed, consumed + take), haveInBuf);
270
- this._bufLen += take;
271
- consumed += take;
272
- if (this._bufLen < frameLen)
273
- return results;
274
- }
275
- const plaintext = this._openRaw(buf.subarray(4, frameLen));
276
- results.push(plaintext);
277
- this._bufLen = 0;
278
- if (this._dead) {
279
- if (consumed < bytes.length) {
280
- this._wipe();
281
- throw new Error('SerpentStreamOpener: unexpected bytes after final chunk');
282
- }
283
- return results;
284
- }
285
- }
286
- // ── Phase 2: parse complete frames directly from bytes ────────────
287
- let pos = consumed;
288
- while (true) {
289
- if (bytes.length - pos < 4)
290
- break;
291
- const sealedLen = (bytes[pos] << 24 | bytes[pos + 1] << 16 | bytes[pos + 2] << 8 | bytes[pos + 3]) >>> 0;
292
- if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
293
- this._wipe();
294
- throw new Error('SerpentStreamOpener: invalid sealed chunk length');
295
- }
296
- const frameLen = 4 + sealedLen;
297
- if (bytes.length - pos < frameLen)
298
- break;
299
- const plaintext = this._openRaw(bytes.subarray(pos + 4, pos + frameLen));
300
- results.push(plaintext);
301
- if (this._dead) {
302
- const remaining = bytes.length - pos - frameLen;
303
- if (remaining > 0) {
304
- this._wipe();
305
- throw new Error('SerpentStreamOpener: unexpected bytes after final chunk');
306
- }
307
- return results;
308
- }
309
- pos += frameLen;
310
- }
311
- // ── Carry over any incomplete trailing bytes into _buf ────────────
312
- const leftover = bytes.length - pos;
313
- if (leftover > 0) {
314
- if (leftover > maxFrame) {
315
- this._wipe();
316
- throw new Error('SerpentStreamOpener: input exceeds maximum frame size');
317
- }
318
- buf.set(bytes.subarray(pos), 0);
319
- this._bufLen = leftover;
320
- }
321
- return results;
322
- }
323
- _wipe() {
324
- if (this._dead)
325
- return;
326
- wipe(this._key);
327
- wipe(this._nonce);
328
- wipe(this._aad);
329
- this._cbc.dispose();
330
- this._hmac.dispose();
331
- this._hkdf.dispose();
332
- if (this._framed) {
333
- wipe(this._buf);
334
- this._bufLen = 0;
335
- }
336
- this._dead = true;
337
- }
338
- dispose() {
339
- if (!this._dead)
340
- this._wipe();
341
- }
342
- }