leviathan-crypto 1.4.0 → 2.0.1

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 (122) hide show
  1. package/CLAUDE.md +129 -94
  2. package/README.md +166 -223
  3. package/SECURITY.md +90 -45
  4. package/dist/chacha20/cipher-suite.d.ts +4 -0
  5. package/dist/chacha20/cipher-suite.js +79 -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 +323 -0
  17. package/dist/docs/architecture.md +427 -292
  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 +94 -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/embedded/serpent.d.ts +1 -1
  32. package/dist/embedded/serpent.js +1 -1
  33. package/dist/errors.d.ts +10 -0
  34. package/dist/errors.js +38 -0
  35. package/dist/fortuna.d.ts +0 -6
  36. package/dist/fortuna.js +5 -5
  37. package/dist/index.d.ts +25 -9
  38. package/dist/index.js +36 -7
  39. package/dist/init.d.ts +3 -7
  40. package/dist/init.js +18 -35
  41. package/dist/keccak/embedded.d.ts +1 -0
  42. package/dist/keccak/embedded.js +27 -0
  43. package/dist/keccak/index.d.ts +4 -0
  44. package/dist/keccak/index.js +31 -0
  45. package/dist/kyber/embedded.d.ts +1 -0
  46. package/dist/kyber/embedded.js +27 -0
  47. package/dist/kyber/indcpa.d.ts +49 -0
  48. package/dist/kyber/indcpa.js +352 -0
  49. package/dist/kyber/index.d.ts +38 -0
  50. package/dist/kyber/index.js +150 -0
  51. package/dist/kyber/kem.d.ts +21 -0
  52. package/dist/kyber/kem.js +160 -0
  53. package/dist/kyber/params.d.ts +14 -0
  54. package/dist/kyber/params.js +37 -0
  55. package/dist/kyber/suite.d.ts +13 -0
  56. package/dist/kyber/suite.js +94 -0
  57. package/dist/kyber/types.d.ts +98 -0
  58. package/dist/kyber/types.js +25 -0
  59. package/dist/kyber/validate.d.ts +19 -0
  60. package/dist/kyber/validate.js +68 -0
  61. package/dist/kyber.wasm +0 -0
  62. package/dist/loader.d.ts +15 -6
  63. package/dist/loader.js +65 -21
  64. package/dist/serpent/cipher-suite.d.ts +4 -0
  65. package/dist/serpent/cipher-suite.js +122 -0
  66. package/dist/serpent/embedded.d.ts +1 -0
  67. package/dist/serpent/embedded.js +27 -0
  68. package/dist/serpent/index.d.ts +6 -37
  69. package/dist/serpent/index.js +9 -118
  70. package/dist/serpent/pool-worker.d.ts +1 -0
  71. package/dist/serpent/pool-worker.js +208 -0
  72. package/dist/serpent/serpent-cbc.d.ts +30 -0
  73. package/dist/serpent/serpent-cbc.js +142 -0
  74. package/dist/serpent.wasm +0 -0
  75. package/dist/sha2/embedded.d.ts +1 -0
  76. package/dist/sha2/embedded.js +27 -0
  77. package/dist/sha2/hkdf.js +6 -2
  78. package/dist/sha2/index.d.ts +3 -2
  79. package/dist/sha2/index.js +3 -4
  80. package/dist/sha3/embedded.d.ts +1 -0
  81. package/dist/sha3/embedded.js +27 -0
  82. package/dist/sha3/index.d.ts +3 -2
  83. package/dist/sha3/index.js +3 -4
  84. package/dist/stream/constants.d.ts +6 -0
  85. package/dist/stream/constants.js +30 -0
  86. package/dist/stream/header.d.ts +9 -0
  87. package/dist/stream/header.js +77 -0
  88. package/dist/stream/index.d.ts +7 -0
  89. package/dist/stream/index.js +27 -0
  90. package/dist/stream/open-stream.d.ts +21 -0
  91. package/dist/stream/open-stream.js +146 -0
  92. package/dist/stream/seal-stream-pool.d.ts +38 -0
  93. package/dist/stream/seal-stream-pool.js +400 -0
  94. package/dist/stream/seal-stream.d.ts +20 -0
  95. package/dist/stream/seal-stream.js +142 -0
  96. package/dist/stream/seal.d.ts +9 -0
  97. package/dist/stream/seal.js +75 -0
  98. package/dist/stream/types.d.ts +25 -0
  99. package/dist/stream/types.js +26 -0
  100. package/dist/utils.d.ts +7 -2
  101. package/dist/utils.js +49 -3
  102. package/dist/wasm-source.d.ts +12 -0
  103. package/dist/wasm-source.js +26 -0
  104. package/package.json +13 -5
  105. package/dist/chacha20/pool.d.ts +0 -52
  106. package/dist/chacha20/pool.js +0 -178
  107. package/dist/chacha20/pool.worker.js +0 -37
  108. package/dist/chacha20/stream-sealer.d.ts +0 -49
  109. package/dist/chacha20/stream-sealer.js +0 -327
  110. package/dist/docs/chacha20_pool.md +0 -309
  111. package/dist/docs/wasm.md +0 -194
  112. package/dist/serpent/seal.d.ts +0 -8
  113. package/dist/serpent/seal.js +0 -72
  114. package/dist/serpent/stream-pool.d.ts +0 -48
  115. package/dist/serpent/stream-pool.js +0 -275
  116. package/dist/serpent/stream-sealer.d.ts +0 -55
  117. package/dist/serpent/stream-sealer.js +0 -342
  118. package/dist/serpent/stream.d.ts +0 -28
  119. package/dist/serpent/stream.js +0 -205
  120. package/dist/serpent/stream.worker.d.ts +0 -32
  121. package/dist/serpent/stream.worker.js +0 -117
  122. /package/dist/chacha20/{pool.worker.d.ts → pool-worker.d.ts} +0 -0
@@ -0,0 +1,7 @@
1
+ export { SealStream } from './seal-stream.js';
2
+ export { OpenStream } from './open-stream.js';
3
+ export { Seal } from './seal.js';
4
+ export type { CipherSuite, DerivedKeys, SealStreamOpts } from './types.js';
5
+ export { FLAG_FRAMED, TAG_DATA, TAG_FINAL, HEADER_SIZE, CHUNK_MIN, CHUNK_MAX, } from './constants.js';
6
+ export { SealStreamPool } from './seal-stream-pool.js';
7
+ export type { PoolOpts } from './seal-stream-pool.js';
@@ -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,400 @@
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
+ // For padded ciphers, validate that a full plaintext chunk fits in the WASM
108
+ // after PKCS7 padding. PKCS7 always adds between 1 and blockSize bytes.
109
+ if (cipher.padded) {
110
+ const paddedFull = chunkSize + 16 - (chunkSize % 16);
111
+ if (paddedFull > cipher.wasmChunkSize)
112
+ throw new RangeError(`leviathan-crypto: chunkSize ${chunkSize} is too large for ${cipher.formatName} ` +
113
+ `(padded full chunk = ${paddedFull}, WASM CHUNK_SIZE = ${cipher.wasmChunkSize}). ` +
114
+ `Use chunkSize \u2264 ${cipher.wasmChunkSize - 1}.`);
115
+ }
116
+ if (key.length !== cipher.keySize)
117
+ throw new RangeError(`key must be ${cipher.keySize} bytes (got ${key.length})`);
118
+ // Generate nonce and derive keys
119
+ const nonce = randomBytes(16);
120
+ const keys = cipher.deriveKeys(key, nonce);
121
+ const header = writeHeader(cipher.formatEnum, framed, nonce, chunkSize);
122
+ // Spawn workers sequentially (compatible with @vitest/web-worker)
123
+ const workers = [];
124
+ for (let i = 0; i < n; i++) {
125
+ const w = cipher.createPoolWorker();
126
+ await new Promise((resolve, reject) => {
127
+ const onMsg = (e) => {
128
+ w.removeEventListener('message', onMsg);
129
+ w.removeEventListener('error', onErr);
130
+ if (e.data.type === 'ready')
131
+ resolve();
132
+ else {
133
+ w.terminate();
134
+ reject(new Error(`worker init failed: ${e.data.message}`));
135
+ }
136
+ };
137
+ const onErr = (e) => {
138
+ w.removeEventListener('message', onMsg);
139
+ w.removeEventListener('error', onErr);
140
+ w.terminate();
141
+ reject(new Error(`worker init failed: ${e.message}`));
142
+ };
143
+ w.addEventListener('message', onMsg);
144
+ w.addEventListener('error', onErr);
145
+ const initKeyBytes = keys.bytes.slice();
146
+ w.postMessage({ type: 'init', modules, derivedKeyBytes: initKeyBytes }, { transfer: [initKeyBytes.buffer] });
147
+ });
148
+ workers.push(w);
149
+ }
150
+ return new SealStreamPool(cipher, workers, keys, key.slice(), header, chunkSize, framed, timeout);
151
+ }
152
+ get header() {
153
+ return this._header;
154
+ }
155
+ get dead() {
156
+ return this._dead;
157
+ }
158
+ get size() {
159
+ return this._workers.length;
160
+ }
161
+ async seal(plaintext) {
162
+ if (this._dead)
163
+ throw new Error('leviathan-crypto: pool is dead');
164
+ if (this._sealed)
165
+ throw new Error('leviathan-crypto: seal() already called on this pool. '
166
+ + 'Create a new pool for each encryption to prevent nonce reuse.');
167
+ const chunkCount = plaintext.length === 0 ? 1 : Math.ceil(plaintext.length / this._chunkSize);
168
+ const jobs = [];
169
+ for (let i = 0; i < chunkCount; i++) {
170
+ const start = i * this._chunkSize;
171
+ const end = Math.min(start + this._chunkSize, plaintext.length);
172
+ const slice = plaintext.slice(start, end);
173
+ const isLast = i === chunkCount - 1;
174
+ const counterNonce = makeCounterNonce(i, isLast ? TAG_FINAL : TAG_DATA);
175
+ jobs.push(this._dispatch({ op: 'seal', counterNonce, data: slice }));
176
+ }
177
+ try {
178
+ const results = await Promise.all(jobs);
179
+ let totalLen = HEADER_SIZE;
180
+ for (const r of results)
181
+ totalLen += this._framed ? r.length + 4 : r.length;
182
+ const ciphertext = new Uint8Array(totalLen);
183
+ ciphertext.set(this._header, 0);
184
+ let pos = HEADER_SIZE;
185
+ for (const r of results) {
186
+ if (this._framed) {
187
+ new DataView(ciphertext.buffer, pos).setUint32(0, r.length, false);
188
+ pos += 4;
189
+ }
190
+ ciphertext.set(r, pos);
191
+ pos += r.length;
192
+ }
193
+ this._sealed = true;
194
+ return ciphertext;
195
+ }
196
+ catch (err) {
197
+ this._killAll(err);
198
+ throw err;
199
+ }
200
+ }
201
+ async open(ciphertext) {
202
+ if (this._dead)
203
+ throw new Error('leviathan-crypto: pool is dead');
204
+ if (ciphertext.length < HEADER_SIZE)
205
+ throw new RangeError(`leviathan-crypto: ciphertext too short — need at least ${HEADER_SIZE} bytes for header`);
206
+ // Validate header before splitting chunks
207
+ const h = readHeader(ciphertext.subarray(0, HEADER_SIZE));
208
+ if (h.formatEnum !== this._cipher.formatEnum)
209
+ throw new Error(`leviathan-crypto: pool expected format 0x${this._cipher.formatEnum.toString(16).padStart(2, '0')}, `
210
+ + `got 0x${h.formatEnum.toString(16).padStart(2, '0')}`);
211
+ if (h.chunkSize !== this._chunkSize)
212
+ throw new RangeError(`leviathan-crypto: pool chunkSize mismatch — pool expects ${this._chunkSize}, `
213
+ + `header says ${h.chunkSize}`);
214
+ if (h.framed !== this._framed)
215
+ throw new Error(`leviathan-crypto: pool framing mismatch — pool is ${this._framed ? 'framed' : 'unframed'}, `
216
+ + `header says ${h.framed ? 'framed' : 'unframed'}`);
217
+ // Re-derive keys from the nonce embedded in this ciphertext's header.
218
+ // The pool's _keys are tied to its own seal nonce — for arbitrary incoming
219
+ // ciphertext the nonce may differ, so we derive fresh keys here.
220
+ if (!this._masterKey)
221
+ throw new Error('leviathan-crypto: pool master key has been wiped');
222
+ const openKeys = this._cipher.deriveKeys(this._masterKey, h.nonce);
223
+ let openKeysWiped = false;
224
+ try {
225
+ // Strip header before chunk splitting
226
+ const body = ciphertext.subarray(HEADER_SIZE);
227
+ if (body.length === 0)
228
+ throw new RangeError('leviathan-crypto: empty ciphertext — seal() always produces at least one chunk');
229
+ // Compute max wire chunk size for per-chunk validation
230
+ const tagSize = this._cipher.tagSize;
231
+ const paddedSize = this._cipher.padded
232
+ ? this._chunkSize + 16 - (this._chunkSize % 16)
233
+ : this._chunkSize;
234
+ const maxWireChunk = paddedSize + tagSize;
235
+ // Split ciphertext body into chunks
236
+ const chunks = [];
237
+ let pos = 0;
238
+ if (this._framed) {
239
+ while (pos < body.length) {
240
+ if (pos + 4 > body.length)
241
+ throw new RangeError('leviathan-crypto: truncated frame header');
242
+ const dv = new DataView(body.buffer, body.byteOffset + pos);
243
+ const len = dv.getUint32(0, false);
244
+ pos += 4;
245
+ if (pos + len > body.length)
246
+ throw new RangeError(`leviathan-crypto: frame claims ${len} bytes but only ${body.length - pos} remain`);
247
+ chunks.push(body.subarray(pos, pos + len));
248
+ pos += len;
249
+ }
250
+ }
251
+ else {
252
+ // Unframed: split by expected wire chunk size
253
+ const fullChunkWire = maxWireChunk;
254
+ while (pos < body.length) {
255
+ const remaining = body.length - pos;
256
+ if (remaining <= fullChunkWire) {
257
+ chunks.push(body.subarray(pos));
258
+ break;
259
+ }
260
+ chunks.push(body.subarray(pos, pos + fullChunkWire));
261
+ pos += fullChunkWire;
262
+ }
263
+ }
264
+ // Validate and dispatch chunks
265
+ const jobs = [];
266
+ for (let i = 0; i < chunks.length; i++) {
267
+ if (chunks[i].length < tagSize)
268
+ throw new RangeError(`leviathan-crypto: chunk ${i} too short — need at least ${tagSize} bytes for tag `
269
+ + `(got ${chunks[i].length})`);
270
+ if (chunks[i].length > maxWireChunk)
271
+ throw new RangeError(`leviathan-crypto: chunk ${i} exceeds max wire size `
272
+ + `(${chunks[i].length} > ${maxWireChunk})`);
273
+ const isLast = i === chunks.length - 1;
274
+ const counterNonce = makeCounterNonce(i, isLast ? TAG_FINAL : TAG_DATA);
275
+ jobs.push(this._dispatch({
276
+ op: 'open', counterNonce, data: chunks[i],
277
+ derivedKeyBytes: openKeys.bytes.slice(),
278
+ }));
279
+ }
280
+ // All per-job key copies made — wipe the main-thread openKeys immediately
281
+ // rather than waiting for Promise.all. earlyWiped tracks this so the
282
+ // finally below only fires on pre-dispatch throws (empty body, frame errors,
283
+ // chunk validation), not as a redundant second call on the normal path.
284
+ this._cipher.wipeKeys(openKeys);
285
+ openKeysWiped = true;
286
+ const results = await Promise.all(jobs);
287
+ let totalLen = 0;
288
+ for (const r of results)
289
+ totalLen += r.length;
290
+ const plaintext = new Uint8Array(totalLen);
291
+ let ptPos = 0;
292
+ for (const r of results) {
293
+ plaintext.set(r, ptPos);
294
+ ptPos += r.length;
295
+ }
296
+ return plaintext;
297
+ }
298
+ catch (err) {
299
+ this._killAll(err);
300
+ throw err;
301
+ }
302
+ finally {
303
+ if (!openKeysWiped)
304
+ this._cipher.wipeKeys(openKeys);
305
+ }
306
+ }
307
+ destroy() {
308
+ this._killAll(new Error('leviathan-crypto: pool destroyed'));
309
+ }
310
+ // ── Internals ────────────────────────────────────────────────────────────
311
+ _dispatch(job) {
312
+ return new Promise((resolve, reject) => {
313
+ if (this._dead) {
314
+ reject(new Error('leviathan-crypto: pool is dead'));
315
+ return;
316
+ }
317
+ const id = this._nextId++;
318
+ const timer = setTimeout(() => {
319
+ this._killAll(new Error(`leviathan-crypto: pool job ${id} timed out after ${this._timeout}ms`));
320
+ }, this._timeout);
321
+ this._pending.set(id, { resolve, reject, timer });
322
+ const worker = this._idle.pop();
323
+ if (worker)
324
+ this._send(worker, { type: 'job', id, ...job });
325
+ else
326
+ this._queue.push({ type: 'job', id, ...job });
327
+ });
328
+ }
329
+ _send(worker, job) {
330
+ const transfer = [];
331
+ // Only transfer data.buffer when the Uint8Array owns the buffer exclusively.
332
+ // Subarrays from open() share the caller's ciphertext buffer — transferring
333
+ // one would detach all sibling views dispatched as parallel jobs.
334
+ if (job.data.buffer instanceof ArrayBuffer
335
+ && job.data.byteOffset === 0
336
+ && job.data.byteLength === job.data.buffer.byteLength)
337
+ transfer.push(job.data.buffer);
338
+ if (job.counterNonce.buffer instanceof ArrayBuffer
339
+ && job.counterNonce.buffer !== job.data.buffer)
340
+ transfer.push(job.counterNonce.buffer);
341
+ if (job.derivedKeyBytes?.buffer instanceof ArrayBuffer)
342
+ transfer.push(job.derivedKeyBytes.buffer);
343
+ // aad is intentionally not transferred — caller may retain the reference
344
+ worker.postMessage(job, { transfer });
345
+ }
346
+ _onMessage(worker, e) {
347
+ const msg = e.data;
348
+ const pending = this._pending.get(msg.id);
349
+ if (!pending)
350
+ return;
351
+ clearTimeout(pending.timer);
352
+ this._pending.delete(msg.id);
353
+ if (msg.type === 'result') {
354
+ pending.resolve(msg.data);
355
+ const next = this._queue.shift();
356
+ if (next)
357
+ this._send(worker, next);
358
+ else
359
+ this._idle.push(worker);
360
+ }
361
+ else {
362
+ const err = msg.isAuthError
363
+ ? new AuthenticationError(msg.cipher)
364
+ : new Error(msg.message);
365
+ pending.reject(err);
366
+ this._killAll(err);
367
+ }
368
+ }
369
+ _onError(e) {
370
+ this._killAll(new Error(`leviathan-crypto: pool worker crashed: ${e.message}`));
371
+ }
372
+ _killAll(error) {
373
+ if (this._dead)
374
+ return;
375
+ this._dead = true;
376
+ for (const { timer } of this._pending.values())
377
+ clearTimeout(timer);
378
+ for (const { reject } of this._pending.values())
379
+ reject(error);
380
+ this._pending.clear();
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;
390
+ this._idle.length = 0;
391
+ if (this._keys) {
392
+ wipe(this._keys.bytes);
393
+ this._keys = null;
394
+ }
395
+ if (this._masterKey) {
396
+ wipe(this._masterKey);
397
+ this._masterKey = null;
398
+ }
399
+ }
400
+ }
@@ -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
+ }