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.
- package/CLAUDE.md +129 -94
- package/README.md +166 -223
- package/SECURITY.md +85 -45
- package/dist/chacha20/cipher-suite.d.ts +4 -0
- package/dist/chacha20/cipher-suite.js +78 -0
- package/dist/chacha20/embedded.d.ts +1 -0
- package/dist/chacha20/embedded.js +27 -0
- package/dist/chacha20/index.d.ts +20 -27
- package/dist/chacha20/index.js +40 -59
- package/dist/chacha20/ops.d.ts +1 -1
- package/dist/chacha20/ops.js +19 -18
- package/dist/chacha20/pool-worker.js +77 -0
- package/dist/ct-wasm.d.ts +1 -0
- package/dist/ct-wasm.js +3 -0
- package/dist/ct.wasm +0 -0
- package/dist/docs/aead.md +320 -0
- package/dist/docs/architecture.md +419 -285
- package/dist/docs/argon2id.md +42 -30
- package/dist/docs/chacha20.md +192 -266
- package/dist/docs/exports.md +241 -0
- package/dist/docs/fortuna.md +60 -69
- package/dist/docs/init.md +172 -178
- package/dist/docs/loader.md +87 -142
- package/dist/docs/serpent.md +134 -583
- package/dist/docs/sha2.md +91 -103
- package/dist/docs/sha3.md +70 -36
- package/dist/docs/types.md +93 -16
- package/dist/docs/utils.md +109 -32
- package/dist/embedded/kyber.d.ts +1 -0
- package/dist/embedded/kyber.js +3 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +38 -0
- package/dist/fortuna.d.ts +0 -6
- package/dist/fortuna.js +5 -5
- package/dist/index.d.ts +25 -9
- package/dist/index.js +36 -7
- package/dist/init.d.ts +3 -7
- package/dist/init.js +18 -35
- package/dist/keccak/embedded.d.ts +1 -0
- package/dist/keccak/embedded.js +27 -0
- package/dist/keccak/index.d.ts +4 -0
- package/dist/keccak/index.js +31 -0
- package/dist/kyber/embedded.d.ts +1 -0
- package/dist/kyber/embedded.js +27 -0
- package/dist/kyber/indcpa.d.ts +49 -0
- package/dist/kyber/indcpa.js +352 -0
- package/dist/kyber/index.d.ts +38 -0
- package/dist/kyber/index.js +150 -0
- package/dist/kyber/kem.d.ts +21 -0
- package/dist/kyber/kem.js +160 -0
- package/dist/kyber/params.d.ts +14 -0
- package/dist/kyber/params.js +37 -0
- package/dist/kyber/suite.d.ts +13 -0
- package/dist/kyber/suite.js +93 -0
- package/dist/kyber/types.d.ts +98 -0
- package/dist/kyber/types.js +25 -0
- package/dist/kyber/validate.d.ts +19 -0
- package/dist/kyber/validate.js +68 -0
- package/dist/kyber.wasm +0 -0
- package/dist/loader.d.ts +15 -6
- package/dist/loader.js +65 -21
- package/dist/serpent/cipher-suite.d.ts +4 -0
- package/dist/serpent/cipher-suite.js +121 -0
- package/dist/serpent/embedded.d.ts +1 -0
- package/dist/serpent/embedded.js +27 -0
- package/dist/serpent/index.d.ts +6 -37
- package/dist/serpent/index.js +9 -118
- package/dist/serpent/pool-worker.d.ts +1 -0
- package/dist/serpent/pool-worker.js +202 -0
- package/dist/serpent/serpent-cbc.d.ts +30 -0
- package/dist/serpent/serpent-cbc.js +136 -0
- package/dist/sha2/embedded.d.ts +1 -0
- package/dist/sha2/embedded.js +27 -0
- package/dist/sha2/hkdf.js +6 -2
- package/dist/sha2/index.d.ts +3 -2
- package/dist/sha2/index.js +3 -4
- package/dist/sha3/embedded.d.ts +1 -0
- package/dist/sha3/embedded.js +27 -0
- package/dist/sha3/index.d.ts +3 -2
- package/dist/sha3/index.js +3 -4
- package/dist/stream/constants.d.ts +6 -0
- package/dist/stream/constants.js +30 -0
- package/dist/stream/header.d.ts +9 -0
- package/dist/stream/header.js +77 -0
- package/dist/stream/index.d.ts +7 -0
- package/dist/stream/index.js +27 -0
- package/dist/stream/open-stream.d.ts +21 -0
- package/dist/stream/open-stream.js +146 -0
- package/dist/stream/seal-stream-pool.d.ts +38 -0
- package/dist/stream/seal-stream-pool.js +391 -0
- package/dist/stream/seal-stream.d.ts +20 -0
- package/dist/stream/seal-stream.js +142 -0
- package/dist/stream/seal.d.ts +9 -0
- package/dist/stream/seal.js +75 -0
- package/dist/stream/types.d.ts +24 -0
- package/dist/stream/types.js +26 -0
- package/dist/utils.d.ts +7 -2
- package/dist/utils.js +49 -3
- package/dist/wasm-source.d.ts +12 -0
- package/dist/wasm-source.js +26 -0
- package/package.json +13 -5
- package/dist/chacha20/pool.d.ts +0 -52
- package/dist/chacha20/pool.js +0 -178
- package/dist/chacha20/pool.worker.js +0 -37
- package/dist/chacha20/stream-sealer.d.ts +0 -49
- package/dist/chacha20/stream-sealer.js +0 -327
- package/dist/docs/chacha20_pool.md +0 -309
- package/dist/docs/wasm.md +0 -194
- package/dist/serpent/seal.d.ts +0 -8
- package/dist/serpent/seal.js +0 -72
- package/dist/serpent/stream-pool.d.ts +0 -48
- package/dist/serpent/stream-pool.js +0 -275
- package/dist/serpent/stream-sealer.d.ts +0 -55
- package/dist/serpent/stream-sealer.js +0 -342
- package/dist/serpent/stream.d.ts +0 -28
- package/dist/serpent/stream.js +0 -205
- package/dist/serpent/stream.worker.d.ts +0 -32
- package/dist/serpent/stream.worker.js +0 -117
- /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
|
+
}
|