Files
suaveplan/packages/foundation/flow/streams/src/transforms/crypto/crypto.md
2026-05-27 23:47:53 +01:00

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: 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

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 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."