7.8 KiB
crypto
(no submodules)
Purpose
The crypto module provides a streaming AEAD transform for @suaveplan/streams. It wraps a one-shot AEAD primitive (XChaCha20-Poly1305 or AES-256-GCM today, any custom engine tomorrow) into a BaseTransform-subclass plugin that can either encrypt or decrypt a stream of Uint8Array chunks. Per-frame nonce binding (noncePrefix || u64be(counter)) and per-frame additional-authenticated-data binding (u64be(counter) || u8(isFinal)) ensure that reorder, duplication, truncation, and tamper attacks all surface as a decryption tag-mismatch and are rethrown as StreamCryptoDecryptFailedError (STREAM.CRYPTO_DECRYPT_FAILED).
Encryption emits a one-time HEADER chunk carrying the random nonce-prefix on stream start, then one length-prefixed frame per input chunk, then a terminating empty isFinal=true frame on flush. Decryption mirrors that: it reads the header first, then frames, and errors out if the stream ends before a final frame is observed. The transform is engine-agnostic: any StreamingAeadEngine that exposes encryptFrame / decryptFrame and declares its keyLength and noncePrefixLength plugs in.
Features
- Streaming AEAD via WHATWG
TransformStream<Uint8Array, Uint8Array> - Engine-pluggable:
xchacha20StreamingEngineandaesGcmStreamingEngineship in-package; custom engines welcome - Per-frame counter and
isFinalflag mixed into both nonce and AAD - Tamper / reorder / duplicate / truncate detection — all surface as
STREAM.CRYPTO_DECRYPT_FAILED - Single-input
process(input)round-trip mode for unit tests / one-off uses - Type-safe hooks (
crypto:before-chunk,crypto:after-chunk) — hooks may replace the chunk - Type-safe events (
crypto:start,crypto:chunk,crypto:complete,crypto:error) - Constructor validation with descriptive
TypeErrorfor engine / key / operation mistakes dispose()zeroizes the private key copy- AbortSignal-aware via
BaseTransform.assertNotAborted
Usage: Basic Example
import {
CryptoTransform,
xchacha20StreamingEngine,
} from "@suaveplan/streams/transforms/crypto/crypto";
const key = crypto.getRandomValues(new Uint8Array(32));
const enc = new CryptoTransform({
engine: xchacha20StreamingEngine,
key,
operation: "encrypt",
});
const dec = new CryptoTransform({
engine: xchacha20StreamingEngine,
key,
operation: "decrypt",
});
const plaintext = new Uint8Array([1, 2, 3, 4, 5]);
const wire = await enc.process(plaintext);
const recovered = await dec.process(wire);
// recovered == plaintext
Usage: Advanced Example
Full streaming pipeline with hook-based redaction and event observability:
import {
aesGcmStreamingEngine,
CryptoTransform,
} from "@suaveplan/streams/transforms/crypto/crypto";
const key = crypto.getRandomValues(new Uint8Array(32));
const enc = new CryptoTransform({
engine: aesGcmStreamingEngine,
key,
operation: "encrypt",
});
enc.on("crypto:start", ({ engineId }) =>
console.log(`encrypt start (engine=${engineId})`)
);
enc.on("crypto:chunk", ({ counter, inBytes, outBytes }) =>
console.log(`frame ${counter}: ${inBytes} -> ${outBytes}`)
);
enc.on("crypto:complete", ({ totalChunks, totalBytes }) =>
console.log(`done: ${totalChunks} frames, ${totalBytes} bytes`)
);
// Mutate plaintext before sealing (redaction example)
enc.hook("crypto:before-chunk", ({ chunk, counter, operation }) => ({
chunk: counter === 0 ? new Uint8Array(chunk.length).fill(0) : chunk,
counter,
operation,
}));
const dec = new CryptoTransform({
engine: aesGcmStreamingEngine,
key,
operation: "decrypt",
});
await source
.pipeThrough(enc.toTransformStream())
.pipeThrough(dec.toTransformStream())
.pipeTo(sink);
enc.dispose(); // zeroize key
dec.dispose();
API Reference
class CryptoTransform extends BaseTransform<CryptoHookMap, CryptoEventMap>
constructor(config: CryptoTransformConfig)
Validates config.engine (must implement StreamingAeadEngine), config.key (must be a Uint8Array of length engine.keyLength), and config.operation (must be "encrypt" or "decrypt"). Throws plain TypeError with a descriptive message on any mismatch. The key is defensively copied; the original buffer is not retained.
process<T>(input: T, options?: TransformOptions): Promise<T>
Single-input mode. Treats input as one Uint8Array. Encrypt produces header || final-frame. Decrypt consumes that exact shape and rejects with StreamCryptoInvalidFrameError if the input is not exactly one final frame after the header.
toTransformStream<A, B>(): TransformStream<A, B>
Streaming mode. On the encrypt side, emits the header chunk on the first input (or on flush if the input was empty), then one length-prefixed frame per input chunk, then a terminating final frame on flush. On the decrypt side, buffers until the header is complete, then drains complete frames, ignoring the final frame's payload but enforcing its presence — flushing without observing a final frame raises StreamCryptoDecryptFailedError.
dispose(): void
Zeroizes the internal key copy via key.fill(0), then calls super.dispose() to clear hooks and listeners.
Engine factories
xchacha20StreamingEngine—id="xchacha20-poly1305",keyLength=32,noncePrefixLength=16, wraps@suaveplan/crypto-cipher::xchacha20poly1305.aesGcmStreamingEngine—id="aes-256-gcm",keyLength=32,noncePrefixLength=4, wraps@suaveplan/crypto-cipher::aes256gcm.
Wire format
HEADER := u8(0x01) || noncePrefix(N)
FRAME := u32be(payloadLen) || u8(isFinal) || ciphertext+tag
STREAM := HEADER FRAME* (last FRAME has isFinal=1, payload may be empty)
N = engine.noncePrefixLength. Per-frame nonce = noncePrefix || u64be(counter). Per-frame AAD = u64be(counter) || u8(isFinal).
Hooks
crypto:before-chunk—{ chunk, counter, operation }. May mutatechunkto redact or replace data before sealing/opening.crypto:after-chunk— fires after sealing/opening;chunkcontains the post-operation bytes.
Events
crypto:start—{ engineId, operation }. Fires once per stream / once perprocesscall.crypto:chunk—{ counter, inBytes, outBytes }. Fires per frame, including the terminating final frame.crypto:complete—{ totalChunks, totalBytes }. Fires on flush / after the last frame on the success path.crypto:error—{ error }. Fires on the failure path.
Implementation Notes
The single-input process(input) path is provided for ergonomic one-shot uses (unit tests, single-message envelopes); streaming via toTransformStream is the canonical path. The wire format is identical between the two — process just always emits exactly one isFinal=true frame at counter 0.
Counter is a JS number; the u64be packing handles values up to 2^53 - 1, which is more than enough for any realistic stream. AES-GCM is the harder case here: the AES-GCM nonce is 12 bytes, of which 4 are the prefix and 8 are the counter — operationally equivalent to the standard "fixed | invocation" partition recommended by NIST SP 800-38D.
The decrypt-side stream buffers incoming bytes in a single growing Uint8Array. For very large frame sizes, callers can shrink memory pressure by using a chunked source upstream — frames are decoded as soon as they are complete, which bounds the resident buffer at one frame plus a partial second frame.
StreamCryptoInvalidFrameError is reserved for structural problems (header tag mismatch, bad isFinal byte, premature end of buffer). StreamCryptoDecryptFailedError is reserved for cryptographic failures (tag mismatch, truncation, reorder). The two error codes let callers distinguish "this isn't AEAD-format data at all" from "this was tampered with."