# 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` - Engine-pluggable: `xchacha20StreamingEngine` and `aesGcmStreamingEngine` ship in-package; custom engines welcome - Per-frame counter and `isFinal` flag 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 `TypeError` for engine / key / operation mistakes - `dispose()` zeroizes the private key copy - AbortSignal-aware via `BaseTransform.assertNotAborted` ## Usage: Basic Example ```ts 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: ```ts 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` #### `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(input: T, options?: TransformOptions): Promise` 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(): TransformStream` 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 ```text 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 mutate `chunk` to redact or replace data before sealing/opening. - `crypto:after-chunk` — fires after sealing/opening; `chunk` contains the post-operation bytes. ### Events - `crypto:start` — `{ engineId, operation }`. Fires once per stream / once per `process` call. - `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."