stream_audit - xero/leviathan-crypto GitHub Wiki
Security audit of the leviathan-crypto streaming AEAD layer (TypeScript, Tier 2). This audit covers composition of audited primitives, not the primitives themselves.
| Meta | Description |
|---|---|
| Conducted: | Week of 2026-04-03 |
| Target: |
leviathan-crypto streaming AEAD layer (TypeScript, Tier 2) |
| Scope: | Composition of audited primitives โ not the primitives themselves |
| Reference paper: | Hoang, Reyhanitabar, Rogaway, Vizรกr โ "Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance" (CRYPTO 2015 / ePrint 2015/189, June 2018 revision) |
| Prior audits cited: | serpent ยง2.4 (Encrypt-then-MAC, Vaudenay padding oracle); chacha ยง1.7 (ChaCha20-Poly1305 AEAD); hkdf ยง1.4 (HKDF stream-layer usage); hmac ยง2.3 (HMAC streaming usage) |
Each chunk gets a unique 12-byte nonce built by makeCounterNonce() (src/ts/stream/header.ts:62โ74). The nonce is two parts: an 11-byte big-endian counter (bytes 0โ10), and a 1-byte final flag (byte 11).
The counter starts at 0 and increments with each push() call (src/ts/stream/seal-stream.ts:75โ77). Data chunks use TAG_DATA = 0x00 as the final byte, and the final chunk uses TAG_FINAL = 0x01.
Distinctness guarantee: A data chunk at counter N has nonce [...N, 0x00] and a final chunk at counter N has nonce [...N, 0x01]. These are distinct because byte 11 differs. Within a stream, the counter increments after each push(), so no two data chunks share a counter value. The final chunk's counter is never reused because finalize() transitions the state machine to 'finalized' and wipes keys.
Verdict: Correct. The 12-byte counter nonce provides unique nonces for all chunks within a stream.
The 20-byte header is written by writeHeader() (src/ts/stream/header.ts:28โ42):
| Offset | Size | Content |
|---|---|---|
| 0 | 1 | Compound byte: (framed ? 0x80 : 0) | formatEnum
|
| 1โ16 | 16 | Random nonce from randomBytes(16)
|
| 17โ19 | 3 | Chunk size as u24 big-endian |
The format enum values are 0x01 (XChaCha20) and 0x02 (Serpent), defined in the respective CipherSuite objects (src/ts/chacha20/cipher-suite.ts:41, src/ts/serpent/cipher-suite.ts:37). The framed flag occupies bit 7 (FLAG_FRAMED = 0x80, constants.ts:26). Bits 0โ5 are available for the format ID: bits 0โ3 are the cipher nibble, bits 4โ5 are the KEM selector (0x00 = no KEM, 0x10 = ML-KEM-512, 0x20 = ML-KEM-768, 0x30 = ML-KEM-1024), and bit 6 is reserved. writeHeader enforces formatEnum โค 0x3f; no explicit mask is needed since bit 6 is never set by any current suite.
The nonce is generated by randomBytes(16) (seal-stream.ts:64), which calls crypto.getRandomValues(). The library does not polyfill this and fails loudly if the API is unavailable.
readHeader() (header.ts:44โ59) parses the header symmetrically and returns a copy of the nonce via .slice(1, 17), not a view, preventing external mutation of the parsed nonce.
Verdict: Correct. The header encodes cipher identity, framing mode, random nonce, and chunk size in a compact, unambiguous 20-byte format.
The 20-byte physical header is the first component of the preamble exposed by
SealStream. For symmetric suites, preamble equals the 20-byte header. For
KEM suites, the format enum's KEM selector (bits 4โ5 of byte 0) signals the
decoder to read kemCtSize additional bytes of KEM ciphertext immediately after
the header; these together form the full preamble. Symmetric suites have KEM
bits = 0x00, so preamble equals header.
Both SealStream and OpenStream maintain a state: 'ready' | 'finalized' field:
SealStream (src/ts/stream/seal-stream.ts):
-
push()(line 70): throws ifstate !== 'ready' -
finalize()(line 82): throws ifstate !== 'ready', then callscipher.wipeKeys(keys)(line 89) and setsstate = 'finalized'(line 90)
OpenStream (src/ts/stream/open-stream.ts):
-
pull()(line 74): throws ifstate !== 'ready' -
finalize()(line 89): throws ifstate !== 'ready', then callscipher.wipeKeys(keys)(line 99) and setsstate = 'finalized'(line 100) -
seek()(line 104): throws ifstate !== 'ready'
SealStreamPool (src/ts/stream/seal-stream-pool.ts):
-
seal()(line 198): throws if_sealedis true, preventing nonce reuse across batch operations -
_sealedis set totrueafter successful seal (line 228)
After finalization, derived keys are wiped via cipher.wipeKeys(keys), which calls wipe(keys.bytes): an in-place .fill(0) on the underlying Uint8Array (utils.ts:148โ150). This is a best-effort wipe in JavaScript (the garbage collector may have already copied the buffer), but it zeroes the canonical reference.
Verdict: Correct. The state machine prevents post-finalization operations and ensures key material is wiped.
SealStream constructor (seal-stream.ts:61โ62):
if (this.chunkSize < CHUNK_MIN || this.chunkSize > CHUNK_MAX)
throw new RangeError(...)
Where CHUNK_MIN = 1024 and CHUNK_MAX = 16_777_215 (u24 max).
push() (seal-stream.ts:72โ73) and finalize() (seal-stream.ts:84โ85): both reject chunks where chunk.length > this.chunkSize. The check uses >, not >=, so chunks exactly equal to chunkSize are accepted.
Empty chunks (length 0) are accepted by both push() and finalize(). This is intentional: the toTransformStream() finalizer calls finalize(new Uint8Array(0)) to close the stream when no data remains.
SealStreamPool.create() (seal-stream-pool.ts:125โ126): validates chunkSize in [CHUNK_MIN, CHUNK_MAX] with the same bounds.
Verdict: Correct. Chunk sizes are validated at construction and enforced per-chunk.
The paper's STREAM construction (Figure 10, Section 7) defines:
โฐ.init(K, N) โ state = (K, N, 1)
โฐ.next(S, A, M): (K, N, i) โ S; C โ E_K(โจN, i, 0โฉ, A, M); return (C, (K, N, i+1))
โฐ.last(S, A, M): (K, N, i) โ S; return E_K(โจN, i, 1โฉ, A, M)
Mapping to the implementation:
| Paper | Implementation | File:Line |
|---|---|---|
| K (same for all chunks) |
cipher.deriveKeys(masterKey, nonce) โ per-stream derived keys |
seal-stream.ts:65 |
| N (stream nonce) | 16-byte random nonce in header, used as HKDF salt | seal-stream.ts:64 |
| โจN, i, 0โฉ (data nonce) | 12-byte counter nonce: 11B counter i + 1B 0x00
|
header.ts:62โ74 |
| โจN, i, 1โฉ (final nonce) | 12-byte counter nonce: 11B counter i + 1B 0x01
|
header.ts:62โ74 |
| i starts at 1 | Counter starts at 0 | seal-stream.ts:46 |
| E_K(nonce, A, M) | cipher.sealChunk(keys, counterNonce, chunk, aad) |
seal-stream.ts:76 |
Divergence #1: Counter origin. The paper starts at i=1; the implementation starts at i=0. This is functionally equivalent: the counter is monotonic and unique within a stream regardless of starting point. No security impact.
Divergence #2: HKDF subkey derivation (critical). The paper uses a single key K for all chunks and embeds the stream nonce N into every per-chunk nonce โจN, i, dโฉ. The implementation derives per-stream subkeys from (masterKey, nonce) via HKDF-SHA-256, then uses bare counter nonces (i, d) without N. The security argument for this substitution is analyzed in ยง2.6.
Seal wraps SealStream with chunkSize = Math.max(plaintext.length, CHUNK_MIN),
then calls finalize() immediately. The resulting blob is:
preamble || finalChunk(counter=0, TAG_FINAL)
OpenStream can decrypt a Seal blob: they share the same wire format. This
is the "one language" invariant: one-shot is the degenerate single-chunk
case of streaming. A Seal blob is accepted anywhere an OpenStream blob is
accepted, and vice versa for single-chunk streams.
The two cipher suites use distinct HKDF info strings:
| Cipher | Info string | Defined at |
|---|---|---|
| XChaCha20 | xchacha20-sealstream-v2 |
src/ts/chacha20/cipher-suite.ts:34 |
| Serpent | serpent-sealstream-v2 |
src/ts/serpent/cipher-suite.ts:34 |
A codebase search confirms no v1 info strings exist: there are no occurrences of serpent-stream-v1, serpent-sealstream-v1, xchacha20-stream-v1, or xchacha20-sealstream-v1 anywhere in the repository. The only two HKDF .derive() call sites are the two cipher suite deriveKeys() methods.
The info strings differ from each other and from any other string in the codebase, providing unambiguous domain separation. Even if the same master key and nonce were used with both cipher suites (which should not happen in practice), the derived keys would differ due to different HKDF info fields. This is confirmed in hkdf_audit.md ยง1.4.
Verdict: Correct. HKDF info strings are unique per cipher and have no v1 collisions.
Full derivation path (src/ts/chacha20/cipher-suite.ts:48โ59):
masterKey(32B) โ HKDF-SHA-256(salt=nonce, info='xchacha20-sealstream-v2', len=32) โ streamKey(32B)
โ HChaCha20(key=streamKey, nonce=nonce[0:16]โ0ร8) โ subkey(32B)
wipe(streamKey)
-
HKDF_SHA256.derive(masterKey, nonce, INFO, 32)produces a 32-bytestreamKey -
deriveSubkey(exports, streamKey, padded)calls HChaCha20 with the first 16 bytes of the stream nonce, padded to 24 bytes with zeros (cipher-suite.ts:55โ57) -
wipe(streamKey)zeroes the intermediate key immediately (cipher-suite.ts:58) - The returned
DerivedKeys.bytesis the 32-byte HChaCha20 subkey
The subkey is used with ChaCha20-Poly1305 (the inner AEAD, not raw ChaCha20) via aeadEncrypt/aeadDecrypt in ops.ts.
The same nonce bytes [0:16] appear in both the HKDF salt and the HChaCha20 input. This is safe: the two operations use different keys (masterKey vs streamKey), and HKDF-SHA-256 produces a computationally independent streamKey before HChaCha20 processes it.
Verdict: Correct. The intermediate streamKey is wiped. The subkey is used with AEAD, not raw encryption.
Full derivation path (src/ts/serpent/cipher-suite.ts:44โ49):
masterKey(32B) โ HKDF-SHA-256(salt=nonce, info='serpent-sealstream-v2', len=96) โ derived(96B)
derived[0:32] = enc_key (Serpent-CBC encryption)
derived[32:64] = mac_key (HMAC-SHA-256 authentication)
derived[64:96] = iv_key (per-chunk CBC IV derivation)
The three keys are extracted via subarray() views into the same 96-byte buffer (cipher-suite.ts:58โ60). They are non-overlapping (bytes 0โ31, 32โ63, 64โ95) and functionally independent, serving distinct cryptographic roles.
Per-chunk CBC IV derivation (cipher-suite.ts:66):
IV = HMAC-SHA-256(iv_key, counterNonce)[0:16]
The IV is deterministic from (iv_key, counterNonce), so it need not be transmitted. Both sides derive the same IV from the same counter nonce and the same iv_key (derived from the same HKDF output). The [0:16] truncation is safe: HMAC-SHA-256 output is a 32-byte PRF value, and the first 16 bytes are a standard-length CBC IV.
Verdict: Correct. Three distinct keys with no overlap. IV derived deterministically.
Two streams with the same master key but different random nonces derive independent subkeys. This follows directly from HKDF properties:
- HKDF uses the nonce as salt:
PRK = HMAC-SHA-256(nonce, masterKey)(hkdf_audit.md ยง1.4) - Different nonces (salts) produce different PRK values
- Different PRKs produce different derived key material
Because each stream generates a fresh 16-byte random nonce via randomBytes(16), streams are cryptographically isolated with overwhelming probability (2^128 collision resistance on the nonce; see ยง2.6 for birthday-bound analysis).
This mechanism replaces the paper's nonce-embedding approach. The paper embeds N in every per-chunk nonce โจN, i, dโฉ to achieve stream separation. The implementation achieves the same separation at the key level via HKDF, then uses bare counter nonces within each stream.
Verdict: Correct. HKDF with distinct random salts provides at least as strong stream isolation as the paper's construction.
SealStream (seal-stream.ts:59โ60):
if (key.length !== cipher.keySize)
throw new RangeError(`key must be ${cipher.keySize} bytes (got ${key.length})`);OpenStream (open-stream.ts:58โ59): identical check.
SealStreamPool (seal-stream-pool.ts:149โ150): identical check.
All three use cipher.keySize, not a hardcoded value. Both XChaCha20Cipher.keySize and SerpentCipher.keySize are 32 (cipher-suite.ts:39 and cipher-suite.ts:39 respectively).
Verdict: Correct. Key size is validated via the cipher's declared size.
All three stream classes check isInitialized('sha2') before construction:
| Class | File:Line | Error message |
|---|---|---|
SealStream |
seal-stream.ts:54โ57 |
"stream layer requires sha2 for key derivation" |
OpenStream |
open-stream.ts:53โ57 |
"stream layer requires sha2 for key derivation" |
SealStreamPool |
seal-stream-pool.ts:118โ122 |
"stream layer requires sha2 for key derivation" |
This check is independent of the cipher: even XChaCha20Cipher requires sha2 because HKDF-SHA-256 is a stream-layer dependency. Tests confirm this: sealstream.test.ts:461โ468 creates a SealStream with only chacha20 initialized and verifies the sha2 error.
Verdict: Correct. The sha2 init gate is present in all stream entry points.
On encrypt, kem.encapsulate(ek) produces sharedSecret and kemCt. HKDF-SHA256
is called with info = encode(hkdfInfo) || kemCt, binding the KEM ciphertext
into the key derivation for defense in depth: the symmetric key is
authenticated against the KEM ciphertext. On decrypt, kem.decapsulate(dk, kemCt)
recovers sharedSecret; the same HKDF invocation with the same kemCt is
applied. The inner cipher's deriveKeys runs on the HKDF output, so all
per-stream key material is derived identically to a symmetric stream.
ML-KEM implicit rejection (FIPS 203 ยง9.3) means decapsulate always returns a
deterministic output and never signals decapsulation failure through an
exception. Authentication failure surfaces via the inner cipher's chunk
authentication instead.
sealChunk (src/ts/chacha20/cipher-suite.ts:62โ76):
Calls aeadEncrypt(exports, keys.bytes, counterNonce, chunk, aad) from ops.ts, which implements RFC 8439 ยง2.8 ChaCha20-Poly1305 AEAD (verified in chacha_audit.md ยง1.7):
- Generate Poly1305 one-time key at counter=0
- Initialize Poly1305, feed AAD + pad16(AAD)
- Encrypt plaintext with ChaCha20 at counter=1
- Feed ciphertext + pad16(CT) to Poly1305
- Feed
le64(aad_len) || le64(ct_len)length footer - Finalize โ 16-byte tag
Output: ciphertext || tag(16) concatenated into a new Uint8Array (cipher-suite.ts:72โ75).
openChunk (src/ts/chacha20/cipher-suite.ts:78โ91):
- Splits input:
ct = chunk[0:-16],tag = chunk[-16:](cipher-suite.ts:85โ86) - Calls
aeadDecrypt(exports, keys.bytes, counterNonce, ct, tag, aad, 'xchacha20-poly1305')fromops.ts -
aeadDecrypt(ops.ts:97โ146):- Computes expected tag via Poly1305 over the same construction
-
Constant-time tag comparison:
constantTimeEqual(expectedTag, tag)(ops.ts:132) - On failure: wipes chunk output buffer (
ops.ts:135), throwsAuthenticationError('xchacha20-poly1305')(ops.ts:136) -
Decrypt only after authentication succeeds (
ops.ts:139โ145)
AAD is passed through to the AEAD: aad ?? new Uint8Array(0) (cipher-suite.ts:70).
Verdict: Correct. AEAD construction follows RFC 8439. Tag comparison is constant-time. Verify-then-decrypt ordering is enforced. Output buffer is wiped on auth failure.
sealChunk (src/ts/serpent/cipher-suite.ts:52โ85):
- Extract keys:
encKey = bytes[0:32],macKey = bytes[32:64],ivKey = bytes[64:96](line 58โ60) -
IV derivation:
HMAC-SHA-256(ivKey, counterNonce)[0:16](line 66) -
Encrypt:
SerpentCbc.encrypt(encKey, iv, chunk)with PKCS7 padding (line 70) -
HMAC tag:
HMAC-SHA-256(macKey, counterNonce || u32be(aad_len) || aad || ct)(lines 74โ77)- The HMAC covers: counter nonce (position binding), AAD length (length disambiguation), AAD, and ciphertext
- AAD length encoded as u32be prevents ambiguity between different AAD/ciphertext partitions
-
Output:
ct || tag(32)(line 84) - Wipe: iv and tagInput zeroed (lines 80โ81)
openChunk (src/ts/serpent/cipher-suite.ts:87โ132):
- Split:
ct = chunk[0:-32],receivedTag = chunk[-32:](lines 98โ99) - IV derivation: same as sealChunk (line 104)
- Compute expected tag: same HMAC construction (lines 107โ110)
-
CRITICAL: Verify BEFORE decrypt (line 115):
On failure: wipes iv, tagInput, expectedTag before throwing (lines 116โ119)
if (!constantTimeEqual(expectedTag, receivedTag)) throw new AuthenticationError('serpent');
-
Decrypt only after auth succeeds:
SerpentCbc.decrypt(encKey, iv, ct)(line 127) - Wipe: tagInput, expectedTag, iv zeroed (lines 122โ129)
The constantTimeEqual function (utils.ts) uses XOR-accumulate over all bytes with no early return. At audit time it shipped as a JS implementation:
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;Note
Post-audit, this comparison was moved into a dedicated WASM SIMD module (v128 XOR-accumulate with branch-free reduction). The JS path was removed; the function now throws a branded error on runtimes without WebAssembly SIMD. The constant-time property the audit relied on is preserved and strengthened; see asm_ct.md for the current implementation.
The minimum ciphertext size is checked by OpenStream.pull() before openChunk is ever called: data.length < cipher.tagSize โ RangeError (open-stream.ts:78โ81). For SerpentCipher, tagSize = 32, so chunks shorter than 32 bytes are rejected before reaching the cipher.
Verdict: Correct. Encrypt-then-MAC with verify-then-decrypt. Constant-time HMAC comparison. No padding oracle: PKCS7 is evaluated only after authentication succeeds (see serpent_audit.md ยง2.4).
Main thread (src/ts/stream/seal-stream-pool.ts:153โ178):
At pool creation, keys are derived on the main thread:
const keys = cipher.deriveKeys(key, nonce);Workers receive derivedKeyBytes: keys.bytes.slice() (line 178): a copy of the derived key bytes. The master key (key) is never sent to workers. Workers receive pre-compiled WebAssembly.Module objects (line 178: modules) and instantiate their own isolated WASM instances with their own WebAssembly.Memory:
-
Serpent worker (
src/ts/serpent/pool-worker.ts:40for sha2 memory and:47for serpent memory): createsnew WebAssembly.Memory({ initial: 3, maximum: 3 })for both sha2 and serpent -
ChaCha20 worker (
src/ts/chacha20/pool-worker.ts:20): createsnew WebAssembly.Memory({ initial: 3, maximum: 3 })
Workers validate key length on init: Serpent expects 96 bytes (pool-worker.ts:55-56), ChaCha20 expects 32 bytes (pool-worker.ts:25-26).
Verdict: Correct. Workers receive derived keys (not master key) and isolated WASM instances.
The _killAll(error) method implements a fatal failure model. State transitions run synchronously; worker teardown is bounded-async via _wipeThenTerminate:
- Idempotent via
_deadflag - Clears all pending job timers
- Rejects all pending promises with the error
- Clears the queue and worker/idle arrays
- For each worker,
_wipeThenTerminateadds a one-shot listener for{ type: 'wiped' }, posts{ type: 'wipe' }, and schedules a 100 ms fallbackterminate()โ whichever fires first wins, and the listener is removed before termination - Synchronously wipes main-thread keys:
wipe(this._keys.bytes); this._keys = null, thenwipe(this._masterKey); this._masterKey = null
The wipe ACK window is best-effort: if the worker honours the message before the 100 ms fallback, it zeroes its subkey/keys buffer and calls wipeBuffers() on its WASM instances before replying { type: 'wiped' }; if it doesn't (mid-job, runtime pause, deadlock), the fallback terminate() still runs. The main-thread _keys.bytes and _masterKey wipes are unconditional and synchronous regardless of the ACK outcome, so the owning surface no longer has access to key material by the time destroy() returns.
_killAll is invoked from:
-
_onMessageon any non-result message (line 344): auth failure -
_onErroron worker crash (line 349) -
_dispatchtimeout handler (line 314): job timeout -
seal()andopen()catch blocks (lines 231, 297): any error -
destroy()(line 301): explicit cleanup
There is no retry logic and no worker replacement. After _killAll, the pool is permanently dead.
Verdict: Correct. Any error is fatal, all state is cleaned up, no partial recovery.
Main thread: _killAll() wipes this._keys.bytes via wipe() (.fill(0)) and sets _keys = null; the _masterKey is zeroed and nulled in the same call. Both wipes are synchronous. The bytesRef test in pool.test.ts:197โ214 confirms the underlying buffer is zeroed in-place.
Workers: _killAll() dispatches each worker through _wipeThenTerminate, which posts { type: 'wipe' } and waits up to 100 ms for a { type: 'wiped' } ACK before calling terminate(). The ACK fires as soon as the wipe handler runs:
-
Serpent worker:
keys.fill(0),sha2.wipeBuffers(),serpent.wipeBuffers(), then sets all toundefined, thenself.postMessage({ type: 'wiped' }) -
ChaCha20 worker:
subkey.fill(0), setssubkey = undefined,x = undefined, thenself.postMessage({ type: 'wiped' })
Both workers also call wipeBuffers() in the finally block of every job, clearing WASM memory after each operation. The ACK handshake closes the racy gap that existed when terminate() could drop a queued wipe message before the handler ran.
Verdict: Correct. Main-thread key material is wiped synchronously. Worker wipes are best-effort under a 100 ms budget โ the handshake closes the previously racy postMessage โ terminate sequence, and on timeout the worker is terminated anyway (no main-thread handle survives). Worker WASM buffers are also wiped after every job.
SealStreamPool.seal() (seal-stream-pool.ts:198โ201):
if (this._sealed) throw new Error(
'leviathan-crypto: seal() already called on this pool. '
+ 'Create a new pool for each encryption to prevent nonce reuse.',
);The _sealed flag is set to true after a successful seal (line 228). This prevents counter/nonce reuse across batch operations: each pool instance can only encrypt one message.
Note: open() does not check _sealed, which is correct. Decryption can be performed multiple times with the same derived keys without security implications.
Verdict: Correct. Single-use encryption guard prevents nonce reuse.
OpenStream validates the header's format enum against the cipher's expected value at construction (src/ts/stream/open-stream.ts:62โ66):
if (h.formatEnum !== cipher.formatEnum)
throw new Error(
`expected format 0x${cipher.formatEnum.toString(16)...}, got 0x${h.formatEnum.toString(16)...}`
);This check occurs before key derivation, so no cryptographic operations run on mismatched input. The error is a plain Error, not an AuthenticationError: this is deliberate, as format mismatch is a structural error, not a cryptographic one.
Tests verify both directions: XChaCha20 header โ SerpentCipher throws, and Serpent header โ XChaCha20Cipher throws (sealstream.test.ts:391โ423). A dedicated test confirms the error is Error and not AuthenticationError (line 410โ423).
Verdict: Correct. Cross-cipher streams are rejected at the format level before any cryptographic operation.
Seal side: When framed = true, each sealed chunk is prefixed with a u32be length (seal-stream.ts:78, 91):
return this.framed ? concat(u32beFrame(result.length), result) : result;The u32beFrame() helper (seal-stream.ts:33โ37) writes the length via DataView.setUint32(0, n, false).
The framed flag is encoded in the header byte's bit 7 (header.ts:35): h[0] = (framed ? FLAG_FRAMED : 0) | (formatEnum & 0x7f).
Open side: readHeader() extracts the framed flag (header.ts:55): framed: !!(byte0 & FLAG_FRAMED). When framed, OpenStream.pull() and finalize() call _stripFrame() (open-stream.ts:112โ122), which:
- Validates chunk โฅ 4 bytes
- Reads u32be length prefix
- Validates
payloadLen === chunk.length - 4 - Returns
chunk.subarray(4)
SealStreamPool handles framing in both seal() (line 222โ226) and open() (lines 244โ257).
An unframed opener receiving framed ciphertext: the framed flag is stored in the header, so the opener will have this.framed = false (from its header's bit 7 being 0) and will not attempt to strip frames. The raw ciphertext bytes (including the 4-byte length prefix) will be passed directly to openChunk(), which will fail authentication because the tag won't verify. This is detected implicitly via AuthenticationError, not via a structural check.
Verdict: Correct. Framed mode writes and validates u32be length prefixes consistently.
OpenStream.seek(index) (src/ts/stream/open-stream.ts:104โ109):
seek(index: number): void {
if (this.state !== 'ready')
throw new Error('OpenStream: cannot seek after finalize');
if (!Number.isInteger(index) || index < 0)
throw new RangeError(`seek index must be a non-negative integer (got ${index})`);
this.counter = index;
}Validation:
- Rejects if finalized
-
Number.isInteger(index)rejects NaN, Infinity, and fractional values -
index < 0rejects negative values - Sets
this.counter = indexdirectly
Tests verify: specific chunk seek, seek-to-0 then sequential read, seek beyond stream (auth failure on wrong chunk), negative index, NaN, fractional values (sealstream.test.ts:238โ294).
Verdict: Correct. Seek validates inputs and enables random-access decryption.
The 16-byte random nonce provides 2^128 bits of collision resistance. Birthday-bound analysis:
- 50% collision probability: ~2^64 streams under the same master key
- At 2^40 streams (~10^12): collision probability โ 2^{-48}, negligible for all practical workloads
- At 2^48 streams: collision probability โ 2^{-32}, still negligible
For context, 2^64 streams at 1 million streams per second would take ~584,942 years.
This matches the birthday bound for 128-bit nonces and is documented in docs/aead.md under "Stream isolation."
A nonce collision between two streams with the same master key would produce identical derived keys, effectively creating two streams encrypted with the same key and counter nonces: a catastrophic nonce reuse. However, 2^64 is the standard security target for 128-bit nonce spaces and is accepted in practice (e.g., XSalsa20 with 192-bit nonces targets 2^96).
Verdict: Acceptable. The 2^64 birthday bound is standard for 128-bit nonces and far exceeds practical usage limits. No additional documentation needed; the security model in docs/aead.md already describes stream isolation.
Serpent: HKDF outputs 96 bytes, split into three 32-byte keys via contiguous subarray() views. The split is unambiguous (0:32, 32:64, 64:96) with no overlap. HKDF output is a PRF when keyed with uniform randomness (RFC 5869 ยง2), so the three keys are computationally independent.
XChaCha20: HKDF outputs 32 bytes, then passed through HChaCha20. The HChaCha20 step is a PRF from key โ subkey (chacha_audit.md ยง1.6), so the final subkey retains the independence properties of the HKDF output.
Domain separation: The info field is sufficient for domain separation per RFC 5869 ยง3.2:
"While the3 'info' value is optional in the definition of HKDF, it is often of great importance in applications. Its main objective is to bind the derived key material to application- and context-specific information."
The two info strings (xchacha20-sealstream-v2, serpent-sealstream-v2) are distinct, non-empty, and uniquely identify the cipher suite. This means the same (masterKey, nonce) pair used with different cipher suites produces independent derived keys.
Known attacks on HKDF output splitting: There are no known attacks against splitting HKDF output into multiple keys when the output is a PRF and the info field provides domain separation. The extract-then-expand paradigm of HKDF is specifically designed for this use case (RFC 5869 ยง1).
Verdict: Correct. HKDF output splitting is safe. Info fields provide domain separation.
The 11-byte counter field supports 2^88 counter values. However, JavaScript's Number.MAX_SAFE_INTEGER limits the actual range to 2^53 - 1.
At 64 KB chunks and 2^53 chunks: 2^53 ร 2^16 = 2^69 bytes โ 590 exabytes. At the minimum chunk size of 1024 bytes: 2^53 ร 2^10 = 2^63 bytes โ 9.2 exabytes. Both are far beyond any practical scenario.
The makeCounterNonce() function (header.ts:67โ70) writes the counter using c & 0xff / Math.floor(c / 256): arithmetic encoding rather than bitwise shifts or DataView. This is correct for all counters up to Number.MAX_SAFE_INTEGER (2^53 - 1) but is slower than a DataView-based approach. If counter exceeds Number.MAX_SAFE_INTEGER, the arithmetic produces incorrect (but non-zero) values due to floating-point precision loss. There is no explicit overflow check.
However, reaching 2^53 chunks is physically impossible in any real system. A counter increment rate of 10 million chunks per second would take ~28.5 years to reach 2^53. The implementation does not silently wrap to zero: JavaScript's Math.floor(c / 256) would produce incorrect (but non-zero) values at the precision boundary, causing authentication failure rather than nonce reuse.
Verdict: Acceptable. Counter overflow is not a practical concern. The absence of an explicit overflow check is a minor robustness gap but has no security impact.
The stream layer (Tier 2) runs in TypeScript. Cryptographic primitives run in WASM (Tier 1).
Tag comparison: Both cipher suites use constantTimeEqual(). At audit time the function shipped as a JS implementation:
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;XOR-accumulate with no early return. The loop always executes all iterations. This provides constant-time comparison at the JavaScript level. True hardware-level constant-time guarantees are not possible in JavaScript, but this pattern is the strongest available.
Note
Post-audit, this comparison was moved into a dedicated WASM SIMD module (v128 XOR-accumulate with branch-free reduction). The JS path was removed; the function now throws a branded error on runtimes without WebAssembly SIMD. The constant-time property is preserved and strengthened: WASM v128 ops bypass the V8/SpiderMonkey scalar JIT passes that the audit's "strongest available in JavaScript" caveat referenced. See asm_ct.md.
Verify-then-decrypt branching:
Both SerpentCipher.openChunk() (line 115) and aeadDecrypt() in ops.ts (line 132) branch on authentication success before decryption:
if (!constantTimeEqual(expectedTag, receivedTag)) throw ...
// decryption only after this pointThis branch is intentional and necessary: it prevents the Cryptographic Doom Principle. The branch does not leak timing information about the tag comparison because:
-
constantTimeEqualtakes constant time regardless of which bytes differ - The branch occurs after the comparison is complete, not during it
- The only information leaked is "authentication succeeded" vs "authentication failed," which is inherent to any AEAD scheme
Error path timing: Different failure modes do not need identical timing because the error type is not secret:
-
RangeError(chunk too short): checked before any crypto -
AuthenticationError: only afterconstantTimeEqual -
Error(format mismatch): checked at construction, before any crypto
Verdict: Acceptable. constantTimeEqual provides the strongest available constant-time guarantee in JavaScript. The verify-then-decrypt branch is necessary and does not leak secret information.
The error hierarchy in order of evaluation:
| Error | When checked | Involves secret data? |
|---|---|---|
Error (sha2 not initialized) |
Construction | No |
RangeError (wrong key length) |
Construction | No |
Error (format mismatch) |
Construction | No |
RangeError (chunk too short) |
Before crypto | No |
Error (state machine) |
Before crypto | No |
AuthenticationError |
After tag comparison | Yes (tag) |
Format and size errors are checked before any cryptographic operation. Only AuthenticationError involves secret data (the MAC tag). An attacker who can observe error types can distinguish at least six categories surfaced by OpenStream.pull against adversarial input:
- State-machine error (
cannot pull in state '<state>') โ structural - Framed chunk too short (< 4 bytes for the length prefix) โ structural
- Framed chunk length mismatch (prefix disagrees with payload) โ structural
- Chunk too short to contain the tag (with numeric leak of the observed length) โ structural
- Chunk exceeds max wire size (with numeric leak of the observed length) โ structural
-
AuthenticationError(after tag comparison) โ cryptographic
Each of these buckets depends only on input sizes the attacker chose, not on secret key bits, plaintext bytes, or MAC comparison internals. The verify-then-decrypt ordering in SerpentCipher and XChaCha20Cipher ensures only the final AuthenticationError bucket depends on secret material, and that bucket has no further internal structure to distinguish. The error hierarchy therefore does not create a cryptographic oracle.
Verdict: Safe. The error hierarchy does not create an oracle. Structural errors precede cryptographic operations, and only one cryptographic error type exists.
The paper's STREAM construction (Section 7, Theorem 2) uses a single key K for all chunks and relies on the constructed nonce โจN, i, dโฉ for both stream isolation and chunk isolation. Leviathan-crypto derives per-stream subkeys via HKDF-SHA-256 and uses bare counter nonces (i, d).
The paper's security argument:
Theorem 2 gives a tight reduction: the nOAE advantage of STREAM[ฮ , โจยทโฉ] is bounded by the nAE advantage of the underlying scheme ฮ . The proof (Appendix E.4) works by showing that each per-chunk encryption call uses a unique (K, nonce) pair:
- Different streams โ different N โ different โจN, i, dโฉ
- Same stream, different chunks โ different i or d โ different โจN, i, dโฉ
Since the underlying nAE scheme ฮ is never called with a repeated nonce, the reduction is one-to-one.
The implementation's modified argument:
The implementation replaces nonce-level isolation with key-level isolation:
-
Inter-stream isolation via HKDF: For two streams with the same masterKey but different random nonces Nโ โ Nโ:
- HKDF-SHA-256(masterKey, Nโ, info) produces derived keys Kโ
- HKDF-SHA-256(masterKey, Nโ, info) produces derived keys Kโ
- By the PRF property of HKDF (RFC 5869 ยง2), Kโ and Kโ are computationally independent when Nโ and Nโ are distinct random salts
- This holds because HKDF-Extract produces
PRK = HMAC-SHA-256(salt, IKM), and HMAC-SHA-256 is a PRF family keyed by salt
-
Intra-stream isolation via counter nonces: Within a single stream, the counter nonce (i, d) provides the same chunk isolation as the paper's โจN, i, dโฉ. The key K is already stream-specific via HKDF, so embedding N in the per-chunk nonce is redundant.
-
Modified security bound: The reduction becomes:
Adv^{nOAE}(A) โค Adv^{PRF}_{HKDF-SHA-256} + Adv^{nAE}_ฮ (B)This adds one term (the PRF advantage of HKDF-SHA-256) to the paper's tight bound. The additional term is:
- Adv^{PRF}_{HKDF-SHA-256}: negligible under the assumption that HMAC-SHA-256 is a PRF (standard assumption, underlying SHA-256 modeled as a random oracle)
The total bound is no longer strictly tight (it has an extra additive term), but the extra term is negligible under standard cryptographic assumptions. In concrete security terms, the per-query PRF advantage of HMAC-SHA-256 is at most 2^{-128}. For q streams under the same master key, the total PRF advantage is roughly q/2^{128} (plus birthday terms on the HMAC internal state), which is negligible at any practical q.
-
Why the substitution is safe: The paper's construction requires that each per-chunk encryption uses a unique nonce. The implementation achieves this differently:
- Paper: unique nonce per chunk via โจN, i, dโฉ, same key K for all streams
- Implementation: unique key per stream via HKDF, unique nonce per chunk within a stream via (i, d)
- Both ensure that no (key, nonce) pair is ever reused across the system
- The implementation's approach is strictly stronger for inter-stream isolation: even if two streams had the same counter value i, they would use different keys, whereas the paper relies on โจNโ, i, dโฉ โ โจNโ, i, dโฉ in the nonce
Verdict: The HKDF divergence preserves the security property from Theorem 2. The modified security bound adds a negligible PRF term for HKDF-SHA-256. The implementation provides equivalent or stronger isolation compared to the paper's construction.
| Property | SealStream/OpenStream | SealStreamPool | KAT |
|---|---|---|---|
| Round-trip (single/multi chunk) | โ | โ | โ |
| Empty final chunk | โ | โ | โ |
| Per-chunk AAD | โ | โ | โ |
| Framed mode | โ | โ | โ |
| Tampered body โ AuthenticationError | โ | โ | โ |
| Tampered tag โ AuthenticationError | โ | โ | โ |
| Chunk reorder โ AuthenticationError | โ | โ | โ |
| Cross-stream replay โ AuthenticationError | โ | โ | โ |
| Cross-cipher rejection | โ | โ | โ |
| State machine (push/pull after finalize) | โ | โ | โ |
| Seal-twice guard | โ | โ | โ |
| Seek (valid, invalid, beyond stream) | โ | โ | โ |
| Key size validation | โ | โ | โ |
| Chunk size validation | โ | โ | โ |
| sha2 init gate | โ | โ | โ |
| Key wipe on destroy | โ | โ | โ |
| Key separation (enc โ mac โ iv) | โ | โ | โ |
| Counter binding (wrong counter โ auth fail) | โ | โ | โ |
| Wire format pinning | โ | โ | โ |
The following properties are not tested but are considered low-risk:
-
Pool with framed mode: Framed encoding is tested in SealStream/OpenStream; pool uses the same
u32beFrame/_stripFramelogic. - Pool with AAD: AAD is passed through to the same cipher.sealChunk/openChunk methods tested in non-pool context.
- TransformStream data flow: Only instantiation is tested. The transform logic is trivial (delegates to push/finalize).
- Counter overflow: Physically unreachable.
-
Worker key wipe verification: Only main-thread key wipe is tested. Worker wipe relies on the
{ type: 'wipe' }message, which is a best-effort path. - Multiple workers distributing work: Pool tests use 1โ2 workers. Distribution is a performance concern, not a security one.
The KAT vectors in test/vectors/sealstream_v2.ts are self-generated by scripts/gen-sealstream-vectors.ts. There is no external authority for the STREAM wire format: these vectors serve as regression trip-wires to detect accidental wire format changes, not as correctness proofs. Correctness is established by the underlying primitive audits and the round-trip tests.
| Document | Description |
|---|---|
| index | Project Documentation index |
| architecture | architecture overview, module relationships, three-tier design |
| authenticated encryption | wire format spec, security model, API reference |
| serpent_audit | Serpent-256 audit, ยง2.4 Verify-then-Decrypt |
| chacha_audit | XChaCha20-Poly1305 audit, ยง1.7 AEAD construction |
| hkdf_audit | HKDF-SHA256 audit, ยง1.4 stream layer usage |
| hmac_audit | HMAC-SHA256 audit, ยง2.3 key separation |
| serpent | SerpentCipher properties |
| chacha20 | XChaCha20Cipher properties |