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
@@ -0,0 +1,27 @@
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/stream/index.ts
23
+ export { SealStream } from './seal-stream.js';
24
+ export { OpenStream } from './open-stream.js';
25
+ export { Seal } from './seal.js';
26
+ export { FLAG_FRAMED, TAG_DATA, TAG_FINAL, HEADER_SIZE, CHUNK_MIN, CHUNK_MAX, } from './constants.js';
27
+ export { SealStreamPool } from './seal-stream-pool.js';
@@ -0,0 +1,21 @@
1
+ import type { CipherSuite } from './types.js';
2
+ export declare class OpenStream {
3
+ readonly chunkSize: number;
4
+ readonly framed: boolean;
5
+ private readonly cipher;
6
+ private readonly keys;
7
+ private readonly maxWireChunk;
8
+ private counter;
9
+ private state;
10
+ constructor(cipher: CipherSuite, key: Uint8Array, preamble: Uint8Array);
11
+ pull(chunk: Uint8Array, opts?: {
12
+ aad?: Uint8Array;
13
+ }): Uint8Array;
14
+ finalize(chunk: Uint8Array, opts?: {
15
+ aad?: Uint8Array;
16
+ }): Uint8Array;
17
+ dispose(): void;
18
+ seek(index: number): void;
19
+ private _stripFrame;
20
+ toTransformStream(): TransformStream<Uint8Array, Uint8Array>;
21
+ }
@@ -0,0 +1,146 @@
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/stream/open-stream.ts
23
+ //
24
+ // OpenStream — cipher-agnostic streaming decryption using the STREAM
25
+ // construction (Hoang/Reyhanitabar/Rogaway/Vizár, CRYPTO 2015).
26
+ import { isInitialized } from '../init.js';
27
+ import { TAG_DATA, TAG_FINAL, CHUNK_MIN, CHUNK_MAX, HEADER_SIZE } from './constants.js';
28
+ import { readHeader, makeCounterNonce } from './header.js';
29
+ export class OpenStream {
30
+ chunkSize;
31
+ framed;
32
+ cipher;
33
+ keys;
34
+ maxWireChunk;
35
+ counter = 0;
36
+ state = 'ready';
37
+ constructor(cipher, key, preamble) {
38
+ this.cipher = cipher;
39
+ if (!isInitialized('sha2'))
40
+ throw new Error('leviathan-crypto: stream layer requires sha2 for key derivation — '
41
+ + 'call init({ sha2: ... }) before creating an OpenStream');
42
+ const decKeySize = cipher.decKeySize ?? cipher.keySize;
43
+ if (key.length !== decKeySize)
44
+ throw new RangeError(`key must be ${decKeySize} bytes (got ${key.length})`);
45
+ const expectedPreambleLen = HEADER_SIZE + cipher.kemCtSize;
46
+ if (preamble.length !== expectedPreambleLen)
47
+ throw new RangeError(`preamble must be exactly ${expectedPreambleLen} bytes (got ${preamble.length})`);
48
+ const h = readHeader(preamble.subarray(0, HEADER_SIZE));
49
+ if (h.formatEnum !== cipher.formatEnum)
50
+ throw new Error(`expected format 0x${cipher.formatEnum.toString(16).padStart(2, '0')} (${cipher.formatName}), `
51
+ + `got 0x${h.formatEnum.toString(16).padStart(2, '0')}`);
52
+ if (h.chunkSize < CHUNK_MIN || h.chunkSize > CHUNK_MAX)
53
+ throw new RangeError(`header chunkSize must be in [${CHUNK_MIN}, ${CHUNK_MAX}] (got ${h.chunkSize})`);
54
+ const kemCt = cipher.kemCtSize > 0
55
+ ? preamble.subarray(HEADER_SIZE, HEADER_SIZE + cipher.kemCtSize)
56
+ : undefined;
57
+ this.keys = cipher.deriveKeys(key, h.nonce, kemCt);
58
+ this.chunkSize = h.chunkSize;
59
+ this.framed = h.framed;
60
+ // Max ciphertext chunk: padded plaintext + tag
61
+ const paddedSize = cipher.padded
62
+ ? h.chunkSize + 16 - (h.chunkSize % 16)
63
+ : h.chunkSize;
64
+ this.maxWireChunk = paddedSize + cipher.tagSize;
65
+ }
66
+ pull(chunk, opts) {
67
+ if (this.state !== 'ready')
68
+ throw new Error('OpenStream: cannot pull after finalize');
69
+ const data = this.framed ? this._stripFrame(chunk) : chunk;
70
+ if (data.length < this.cipher.tagSize)
71
+ throw new RangeError(`chunk too short to contain ${this.cipher.tagSize}-byte tag (got ${data.length} bytes)`);
72
+ if (data.length > this.maxWireChunk)
73
+ 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
+ }
79
+ finalize(chunk, opts) {
80
+ if (this.state !== 'ready')
81
+ throw new Error('OpenStream: already finalized');
82
+ const data = this.framed ? this._stripFrame(chunk) : chunk;
83
+ if (data.length < this.cipher.tagSize)
84
+ throw new RangeError(`chunk too short to contain ${this.cipher.tagSize}-byte tag (got ${data.length} bytes)`);
85
+ if (data.length > this.maxWireChunk)
86
+ 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;
92
+ }
93
+ dispose() {
94
+ if (this.state === 'ready') {
95
+ this.cipher.wipeKeys(this.keys);
96
+ this.state = 'finalized';
97
+ }
98
+ }
99
+ seek(index) {
100
+ 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})`);
104
+ this.counter = index;
105
+ }
106
+ _stripFrame(chunk) {
107
+ if (chunk.length < 4)
108
+ throw new RangeError(`framed chunk too short — need at least 4 bytes (got ${chunk.length})`);
109
+ const dv = new DataView(chunk.buffer, chunk.byteOffset);
110
+ const payloadLen = dv.getUint32(0, false);
111
+ if (payloadLen !== chunk.length - 4)
112
+ throw new RangeError(`framed chunk length mismatch — prefix says ${payloadLen}, actual payload is ${chunk.length - 4}`);
113
+ return chunk.subarray(4);
114
+ }
115
+ toTransformStream() {
116
+ let buffered = null;
117
+ return new TransformStream({
118
+ transform: (chunk, controller) => {
119
+ try {
120
+ if (buffered !== null) {
121
+ controller.enqueue(this.pull(buffered));
122
+ }
123
+ buffered = chunk;
124
+ }
125
+ catch (err) {
126
+ this.dispose();
127
+ throw err;
128
+ }
129
+ },
130
+ flush: (controller) => {
131
+ try {
132
+ if (buffered !== null) {
133
+ controller.enqueue(this.finalize(buffered));
134
+ }
135
+ else {
136
+ this.dispose(); // no chunks piped — wipe keys, emit nothing
137
+ }
138
+ }
139
+ catch (err) {
140
+ this.dispose();
141
+ throw err;
142
+ }
143
+ },
144
+ });
145
+ }
146
+ }
@@ -0,0 +1,38 @@
1
+ import type { WasmSource } from '../wasm-source.js';
2
+ import type { CipherSuite } from './types.js';
3
+ export interface PoolOpts {
4
+ wasm: WasmSource | Record<string, WasmSource>;
5
+ workers?: number;
6
+ chunkSize?: number;
7
+ framed?: boolean;
8
+ jobTimeout?: number;
9
+ }
10
+ export declare class SealStreamPool {
11
+ private readonly _cipher;
12
+ private readonly _chunkSize;
13
+ private readonly _framed;
14
+ private readonly _timeout;
15
+ private readonly _header;
16
+ private _workers;
17
+ private _idle;
18
+ private _queue;
19
+ private _pending;
20
+ private _nextId;
21
+ private _dead;
22
+ private _sealed;
23
+ private _keys;
24
+ private _masterKey;
25
+ private constructor();
26
+ static create(cipher: CipherSuite, key: Uint8Array, opts: PoolOpts): Promise<SealStreamPool>;
27
+ get header(): Uint8Array;
28
+ get dead(): boolean;
29
+ get size(): number;
30
+ seal(plaintext: Uint8Array): Promise<Uint8Array>;
31
+ open(ciphertext: Uint8Array): Promise<Uint8Array>;
32
+ destroy(): void;
33
+ private _dispatch;
34
+ private _send;
35
+ private _onMessage;
36
+ private _onError;
37
+ private _killAll;
38
+ }
@@ -0,0 +1,391 @@
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/stream/seal-stream-pool.ts
23
+ //
24
+ // SealStreamPool — parallel batch encryption/decryption using the STREAM
25
+ // construction. Dispatches per-chunk seal/open jobs across Web Workers.
26
+ // Any error is fatal: auth failure, crash, or timeout kills all workers,
27
+ // wipes keys, and rejects all pending promises.
28
+ import { randomBytes, wipe } from '../utils.js';
29
+ import { isInitialized } from '../init.js';
30
+ import { compileWasm } from '../loader.js';
31
+ import { AuthenticationError } from '../errors.js';
32
+ import { CHUNK_MIN, CHUNK_MAX, HEADER_SIZE, TAG_DATA, TAG_FINAL } from './constants.js';
33
+ import { writeHeader, readHeader, makeCounterNonce } from './header.js';
34
+ function isRecord(v) {
35
+ return typeof v === 'object' && v !== null
36
+ && !(v instanceof Uint8Array) && !(v instanceof ArrayBuffer)
37
+ && !(v instanceof URL) && !(v instanceof WebAssembly.Module)
38
+ && !(typeof Response !== 'undefined' && v instanceof Response)
39
+ && typeof v.then !== 'function';
40
+ }
41
+ export class SealStreamPool {
42
+ _cipher;
43
+ _chunkSize;
44
+ _framed;
45
+ _timeout;
46
+ _header;
47
+ _workers;
48
+ _idle;
49
+ _queue;
50
+ _pending;
51
+ _nextId;
52
+ _dead;
53
+ _sealed;
54
+ _keys;
55
+ _masterKey;
56
+ constructor(cipher, workers, keys, masterKey, header, chunkSize, framed, timeout) {
57
+ this._cipher = cipher;
58
+ this._workers = workers;
59
+ this._idle = [...workers];
60
+ this._queue = [];
61
+ this._pending = new Map();
62
+ this._nextId = 0;
63
+ this._dead = false;
64
+ this._sealed = false;
65
+ this._keys = keys;
66
+ this._masterKey = masterKey;
67
+ this._header = header;
68
+ this._chunkSize = chunkSize;
69
+ this._framed = framed;
70
+ this._timeout = timeout;
71
+ for (const w of workers) {
72
+ w.onmessage = (e) => this._onMessage(w, e);
73
+ w.onerror = (e) => this._onError(e);
74
+ }
75
+ }
76
+ static async create(cipher, key, opts) {
77
+ if (!isInitialized('sha2'))
78
+ throw new Error('leviathan-crypto: stream layer requires sha2 for key derivation — '
79
+ + 'call init({ sha2: ... }) before creating a SealStreamPool');
80
+ if (cipher.kemCtSize > 0)
81
+ throw new Error('leviathan-crypto: SealStreamPool does not support KEM-enabled cipher suites — '
82
+ + 'KEM encryption is asymmetric (seal uses encapsulation key, open requires decapsulation key) '
83
+ + 'and cannot share a single key across both directions. '
84
+ + 'Use SealStream / OpenStream directly for hybrid KEM encryption.');
85
+ const chunkSize = opts.chunkSize ?? 65536;
86
+ if (chunkSize < CHUNK_MIN || chunkSize > CHUNK_MAX)
87
+ throw new RangeError(`chunkSize must be in [${CHUNK_MIN}, ${CHUNK_MAX}] (got ${chunkSize})`);
88
+ const framed = opts.framed ?? false;
89
+ const timeout = opts.jobTimeout ?? 30_000;
90
+ const n = opts.workers ?? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined) ?? 4;
91
+ // Compile WASM modules
92
+ const modules = {};
93
+ const required = cipher.wasmModules;
94
+ if (isRecord(opts.wasm)) {
95
+ const record = opts.wasm;
96
+ for (const mod of required) {
97
+ if (!(mod in record))
98
+ throw new Error(`leviathan-crypto: pool requires WASM module '${mod}' (required: ${required.join(', ')})`);
99
+ modules[mod] = await compileWasm(record[mod]);
100
+ }
101
+ }
102
+ else {
103
+ if (required.length > 1)
104
+ throw new Error(`leviathan-crypto: cipher requires ${required.length} WASM modules (${required.join(', ')}) — provide a Record`);
105
+ modules[required[0]] = await compileWasm(opts.wasm);
106
+ }
107
+ if (key.length !== cipher.keySize)
108
+ throw new RangeError(`key must be ${cipher.keySize} bytes (got ${key.length})`);
109
+ // Generate nonce and derive keys
110
+ const nonce = randomBytes(16);
111
+ const keys = cipher.deriveKeys(key, nonce);
112
+ const header = writeHeader(cipher.formatEnum, framed, nonce, chunkSize);
113
+ // Spawn workers sequentially (compatible with @vitest/web-worker)
114
+ const workers = [];
115
+ for (let i = 0; i < n; i++) {
116
+ const w = cipher.createPoolWorker();
117
+ await new Promise((resolve, reject) => {
118
+ const onMsg = (e) => {
119
+ w.removeEventListener('message', onMsg);
120
+ w.removeEventListener('error', onErr);
121
+ if (e.data.type === 'ready')
122
+ resolve();
123
+ else {
124
+ w.terminate();
125
+ reject(new Error(`worker init failed: ${e.data.message}`));
126
+ }
127
+ };
128
+ const onErr = (e) => {
129
+ w.removeEventListener('message', onMsg);
130
+ w.removeEventListener('error', onErr);
131
+ w.terminate();
132
+ reject(new Error(`worker init failed: ${e.message}`));
133
+ };
134
+ w.addEventListener('message', onMsg);
135
+ w.addEventListener('error', onErr);
136
+ const initKeyBytes = keys.bytes.slice();
137
+ w.postMessage({ type: 'init', modules, derivedKeyBytes: initKeyBytes }, { transfer: [initKeyBytes.buffer] });
138
+ });
139
+ workers.push(w);
140
+ }
141
+ return new SealStreamPool(cipher, workers, keys, key.slice(), header, chunkSize, framed, timeout);
142
+ }
143
+ get header() {
144
+ return this._header;
145
+ }
146
+ get dead() {
147
+ return this._dead;
148
+ }
149
+ get size() {
150
+ return this._workers.length;
151
+ }
152
+ async seal(plaintext) {
153
+ if (this._dead)
154
+ throw new Error('leviathan-crypto: pool is dead');
155
+ if (this._sealed)
156
+ throw new Error('leviathan-crypto: seal() already called on this pool. '
157
+ + 'Create a new pool for each encryption to prevent nonce reuse.');
158
+ const chunkCount = plaintext.length === 0 ? 1 : Math.ceil(plaintext.length / this._chunkSize);
159
+ const jobs = [];
160
+ for (let i = 0; i < chunkCount; i++) {
161
+ const start = i * this._chunkSize;
162
+ const end = Math.min(start + this._chunkSize, plaintext.length);
163
+ const slice = plaintext.slice(start, end);
164
+ const isLast = i === chunkCount - 1;
165
+ const counterNonce = makeCounterNonce(i, isLast ? TAG_FINAL : TAG_DATA);
166
+ jobs.push(this._dispatch({ op: 'seal', counterNonce, data: slice }));
167
+ }
168
+ try {
169
+ const results = await Promise.all(jobs);
170
+ let totalLen = HEADER_SIZE;
171
+ for (const r of results)
172
+ totalLen += this._framed ? r.length + 4 : r.length;
173
+ const ciphertext = new Uint8Array(totalLen);
174
+ ciphertext.set(this._header, 0);
175
+ let pos = HEADER_SIZE;
176
+ for (const r of results) {
177
+ if (this._framed) {
178
+ new DataView(ciphertext.buffer, pos).setUint32(0, r.length, false);
179
+ pos += 4;
180
+ }
181
+ ciphertext.set(r, pos);
182
+ pos += r.length;
183
+ }
184
+ this._sealed = true;
185
+ return ciphertext;
186
+ }
187
+ catch (err) {
188
+ this._killAll(err);
189
+ throw err;
190
+ }
191
+ }
192
+ async open(ciphertext) {
193
+ if (this._dead)
194
+ throw new Error('leviathan-crypto: pool is dead');
195
+ if (ciphertext.length < HEADER_SIZE)
196
+ throw new RangeError(`leviathan-crypto: ciphertext too short — need at least ${HEADER_SIZE} bytes for header`);
197
+ // Validate header before splitting chunks
198
+ const h = readHeader(ciphertext.subarray(0, HEADER_SIZE));
199
+ if (h.formatEnum !== this._cipher.formatEnum)
200
+ throw new Error(`leviathan-crypto: pool expected format 0x${this._cipher.formatEnum.toString(16).padStart(2, '0')}, `
201
+ + `got 0x${h.formatEnum.toString(16).padStart(2, '0')}`);
202
+ if (h.chunkSize !== this._chunkSize)
203
+ throw new RangeError(`leviathan-crypto: pool chunkSize mismatch — pool expects ${this._chunkSize}, `
204
+ + `header says ${h.chunkSize}`);
205
+ if (h.framed !== this._framed)
206
+ throw new Error(`leviathan-crypto: pool framing mismatch — pool is ${this._framed ? 'framed' : 'unframed'}, `
207
+ + `header says ${h.framed ? 'framed' : 'unframed'}`);
208
+ // Re-derive keys from the nonce embedded in this ciphertext's header.
209
+ // The pool's _keys are tied to its own seal nonce — for arbitrary incoming
210
+ // ciphertext the nonce may differ, so we derive fresh keys here.
211
+ if (!this._masterKey)
212
+ throw new Error('leviathan-crypto: pool master key has been wiped');
213
+ const openKeys = this._cipher.deriveKeys(this._masterKey, h.nonce);
214
+ let openKeysWiped = false;
215
+ try {
216
+ // Strip header before chunk splitting
217
+ const body = ciphertext.subarray(HEADER_SIZE);
218
+ if (body.length === 0)
219
+ throw new RangeError('leviathan-crypto: empty ciphertext — seal() always produces at least one chunk');
220
+ // Compute max wire chunk size for per-chunk validation
221
+ const tagSize = this._cipher.tagSize;
222
+ const paddedSize = this._cipher.padded
223
+ ? this._chunkSize + 16 - (this._chunkSize % 16)
224
+ : this._chunkSize;
225
+ const maxWireChunk = paddedSize + tagSize;
226
+ // Split ciphertext body into chunks
227
+ const chunks = [];
228
+ let pos = 0;
229
+ if (this._framed) {
230
+ while (pos < body.length) {
231
+ if (pos + 4 > body.length)
232
+ throw new RangeError('leviathan-crypto: truncated frame header');
233
+ const dv = new DataView(body.buffer, body.byteOffset + pos);
234
+ const len = dv.getUint32(0, false);
235
+ pos += 4;
236
+ if (pos + len > body.length)
237
+ throw new RangeError(`leviathan-crypto: frame claims ${len} bytes but only ${body.length - pos} remain`);
238
+ chunks.push(body.subarray(pos, pos + len));
239
+ pos += len;
240
+ }
241
+ }
242
+ else {
243
+ // Unframed: split by expected wire chunk size
244
+ const fullChunkWire = maxWireChunk;
245
+ while (pos < body.length) {
246
+ const remaining = body.length - pos;
247
+ if (remaining <= fullChunkWire) {
248
+ chunks.push(body.subarray(pos));
249
+ break;
250
+ }
251
+ chunks.push(body.subarray(pos, pos + fullChunkWire));
252
+ pos += fullChunkWire;
253
+ }
254
+ }
255
+ // Validate and dispatch chunks
256
+ const jobs = [];
257
+ for (let i = 0; i < chunks.length; i++) {
258
+ if (chunks[i].length < tagSize)
259
+ throw new RangeError(`leviathan-crypto: chunk ${i} too short — need at least ${tagSize} bytes for tag `
260
+ + `(got ${chunks[i].length})`);
261
+ if (chunks[i].length > maxWireChunk)
262
+ throw new RangeError(`leviathan-crypto: chunk ${i} exceeds max wire size `
263
+ + `(${chunks[i].length} > ${maxWireChunk})`);
264
+ const isLast = i === chunks.length - 1;
265
+ const counterNonce = makeCounterNonce(i, isLast ? TAG_FINAL : TAG_DATA);
266
+ jobs.push(this._dispatch({
267
+ op: 'open', counterNonce, data: chunks[i],
268
+ derivedKeyBytes: openKeys.bytes.slice(),
269
+ }));
270
+ }
271
+ // All per-job key copies made — wipe the main-thread openKeys immediately
272
+ // rather than waiting for Promise.all. earlyWiped tracks this so the
273
+ // finally below only fires on pre-dispatch throws (empty body, frame errors,
274
+ // chunk validation), not as a redundant second call on the normal path.
275
+ this._cipher.wipeKeys(openKeys);
276
+ openKeysWiped = true;
277
+ const results = await Promise.all(jobs);
278
+ let totalLen = 0;
279
+ for (const r of results)
280
+ totalLen += r.length;
281
+ const plaintext = new Uint8Array(totalLen);
282
+ let ptPos = 0;
283
+ for (const r of results) {
284
+ plaintext.set(r, ptPos);
285
+ ptPos += r.length;
286
+ }
287
+ return plaintext;
288
+ }
289
+ catch (err) {
290
+ this._killAll(err);
291
+ throw err;
292
+ }
293
+ finally {
294
+ if (!openKeysWiped)
295
+ this._cipher.wipeKeys(openKeys);
296
+ }
297
+ }
298
+ destroy() {
299
+ this._killAll(new Error('leviathan-crypto: pool destroyed'));
300
+ }
301
+ // ── Internals ────────────────────────────────────────────────────────────
302
+ _dispatch(job) {
303
+ return new Promise((resolve, reject) => {
304
+ if (this._dead) {
305
+ reject(new Error('leviathan-crypto: pool is dead'));
306
+ return;
307
+ }
308
+ const id = this._nextId++;
309
+ const timer = setTimeout(() => {
310
+ this._killAll(new Error(`leviathan-crypto: pool job ${id} timed out after ${this._timeout}ms`));
311
+ }, this._timeout);
312
+ this._pending.set(id, { resolve, reject, timer });
313
+ const worker = this._idle.pop();
314
+ if (worker)
315
+ this._send(worker, { type: 'job', id, ...job });
316
+ else
317
+ this._queue.push({ type: 'job', id, ...job });
318
+ });
319
+ }
320
+ _send(worker, job) {
321
+ const transfer = [];
322
+ // Only transfer data.buffer when the Uint8Array owns the buffer exclusively.
323
+ // Subarrays from open() share the caller's ciphertext buffer — transferring
324
+ // one would detach all sibling views dispatched as parallel jobs.
325
+ if (job.data.buffer instanceof ArrayBuffer
326
+ && job.data.byteOffset === 0
327
+ && job.data.byteLength === job.data.buffer.byteLength)
328
+ transfer.push(job.data.buffer);
329
+ if (job.counterNonce.buffer instanceof ArrayBuffer
330
+ && job.counterNonce.buffer !== job.data.buffer)
331
+ transfer.push(job.counterNonce.buffer);
332
+ if (job.derivedKeyBytes?.buffer instanceof ArrayBuffer)
333
+ transfer.push(job.derivedKeyBytes.buffer);
334
+ // aad is intentionally not transferred — caller may retain the reference
335
+ worker.postMessage(job, { transfer });
336
+ }
337
+ _onMessage(worker, e) {
338
+ const msg = e.data;
339
+ const pending = this._pending.get(msg.id);
340
+ if (!pending)
341
+ return;
342
+ clearTimeout(pending.timer);
343
+ this._pending.delete(msg.id);
344
+ if (msg.type === 'result') {
345
+ pending.resolve(msg.data);
346
+ const next = this._queue.shift();
347
+ if (next)
348
+ this._send(worker, next);
349
+ else
350
+ this._idle.push(worker);
351
+ }
352
+ else {
353
+ const err = msg.isAuthError
354
+ ? new AuthenticationError(msg.cipher)
355
+ : new Error(msg.message);
356
+ pending.reject(err);
357
+ this._killAll(err);
358
+ }
359
+ }
360
+ _onError(e) {
361
+ this._killAll(new Error(`leviathan-crypto: pool worker crashed: ${e.message}`));
362
+ }
363
+ _killAll(error) {
364
+ if (this._dead)
365
+ return;
366
+ this._dead = true;
367
+ for (const { timer } of this._pending.values())
368
+ clearTimeout(timer);
369
+ for (const { reject } of this._pending.values())
370
+ reject(error);
371
+ this._pending.clear();
372
+ this._queue.length = 0;
373
+ for (const w of this._workers) {
374
+ try {
375
+ w.postMessage({ type: 'wipe' });
376
+ }
377
+ catch { /* worker may be terminated */ }
378
+ w.terminate();
379
+ }
380
+ this._workers.length = 0;
381
+ this._idle.length = 0;
382
+ if (this._keys) {
383
+ wipe(this._keys.bytes);
384
+ this._keys = null;
385
+ }
386
+ if (this._masterKey) {
387
+ wipe(this._masterKey);
388
+ this._masterKey = null;
389
+ }
390
+ }
391
+ }
@@ -0,0 +1,20 @@
1
+ import type { CipherSuite, SealStreamOpts } from './types.js';
2
+ export declare class SealStream {
3
+ /** Preamble sent before the first chunk: header [|| kemCiphertext]. */
4
+ readonly preamble: Uint8Array;
5
+ private readonly cipher;
6
+ private readonly keys;
7
+ private readonly chunkSize;
8
+ private readonly framed;
9
+ private counter;
10
+ private state;
11
+ constructor(cipher: CipherSuite, key: Uint8Array, opts?: SealStreamOpts);
12
+ push(chunk: Uint8Array, opts?: {
13
+ aad?: Uint8Array;
14
+ }): Uint8Array;
15
+ finalize(chunk: Uint8Array, opts?: {
16
+ aad?: Uint8Array;
17
+ }): Uint8Array;
18
+ dispose(): void;
19
+ toTransformStream(): TransformStream<Uint8Array, Uint8Array>;
20
+ }