Files
skills/docs/security-stack/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.md
2026-05-29 08:20:56 +01:00

4.6 KiB

streaming-aead

(no submodules)

Purpose

streaming-aead.ts is the engine-agnostic streaming AEAD generator pair (encryptStreamAead / decryptStreamAead) in @suaveplan/crypto-cipher. It frames an arbitrary AsyncIterable<Uint8Array> source of plaintext into a self-describing wire format with per-chunk authentication and a final-flag terminator that binds the total chunk count into the AEAD tag, so silent truncation of the trailing chunk is impossible.

The generators delegate per-chunk crypto to a pluggable StreamingAeadEngine (default: xchacha20StreamingEngine). The engine boundary is the substitution point that lets callers swap in AES-256-GCM, XChaCha20-Poly1305, or a hardware-backed engine without changing the framing protocol or the public API surface.

Features

  • Async-generator API consumes any AsyncIterable<Uint8Array> or sync Iterable<Uint8Array> source.
  • Framed wire format: 8-byte header prefix + variable-length base nonce, then [ciphertextLen BE | finalFlag | ciphertext‖tag] per chunk.
  • Final-flag terminator chunk binds the total chunk count into the AEAD tag.
  • AbortSignal propagation — aborting flushes the in-flight chunk and emits STREAM_ABORTED.
  • Optional onHeader / onChunk callbacks for attestation and observability without breaking the streaming contract.
  • Engine pluggability (the default is XChaCha20-Poly1305; AES-256-GCM is available via aesGcmStreamingEngine).
  • Counter-overflow guard at 2^32 chunks — emits STREAM_OUT_OF_ORDER.
  • Tag-mismatch detection on every chunk emits STREAM_TAG_MISMATCH.
  • allowUnterminated opt-in for streams produced by upstream tooling that omit the final-flag.

Usage: Basic Example

import {
    encryptStreamAead,
    decryptStreamAead,
} from "@suaveplan/crypto-cipher/streaming/streaming-aead";

async function* source() {
    yield new TextEncoder().encode("part one ");
    yield new TextEncoder().encode("part two");
}

const key = crypto.getRandomValues(new Uint8Array(32));
const wire: Uint8Array[] = [];
for await (const chunk of encryptStreamAead(key, source())) {
    wire.push(chunk);
}

async function* replay() {
    for (const c of wire) yield c;
}

const out: Uint8Array[] = [];
for await (const chunk of decryptStreamAead(key, replay())) {
    out.push(chunk);
}

Usage: Advanced Example

import {
    encryptStreamAead,
    decryptStreamAead,
} from "@suaveplan/crypto-cipher/streaming/streaming-aead";
import { aesGcmStreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-aes-gcm";

const key = crypto.getRandomValues(new Uint8Array(32));
const baseNonce = crypto.getRandomValues(new Uint8Array(8));
const ctrl = new AbortController();

setTimeout(() => ctrl.abort(), 250); // abort half-way through a long stream

try {
    for await (const chunk of encryptStreamAead(key, longSource(), {
        engine: aesGcmStreamingEngine,
        baseNonce,
        signal: ctrl.signal,
        onHeader: (h) => console.log("header", h.engineId),
        onChunk: (t) => console.log("chunk", t.counter, t.finalFlag),
    })) {
        sink.write(chunk);
    }
} catch (err) {
    if (err instanceof Error && (err as { code?: string }).code === "CRYPTO.CIPHER.STREAM_ABORTED") {
        console.warn("aborted at chunk", (err as { context?: { chunksProcessed: number } }).context?.chunksProcessed);
    }
}

API Reference

  • encryptStreamAead(key, source, options?): AsyncGenerator<Uint8Array, void> — frames source chunk-by-chunk and yields the wire bytes.
  • decryptStreamAead(key, source, options?): AsyncGenerator<Uint8Array, void> — consumes wire bytes from source, yields recovered plaintext chunks.
  • EncryptStreamAeadOptions { engine?, baseNonce?, signal?, onHeader?, onChunk? }.
  • DecryptStreamAeadOptions { engine?, allowUnterminated?, signal?, onHeader?, onChunk? }.

Errors: every failure path throws CryptoCipherError with one of STREAM_TAG_MISMATCH, STREAM_OUT_OF_ORDER, STREAM_ABORTED, STREAM_INVALID_NONCE_BASE.

Implementation Notes

The wire header is 8 bytes (magic[4] || engineWireId[1] || version[1] || baseNonceLen[2]) followed by the base nonce. Each chunk frame is [ciphertextLen BE | finalFlag | ciphertext‖tag]. The terminator frame uses an empty plaintext under finalFlag=1 so the AEAD tag binds the chunk count — a truncated stream that omits the terminator fails at decrypt time with STREAM_TAG_MISMATCH. The high bit of the 32-bit chunk counter carries the final-flag bit, so terminator and data chunks at the same counter index produce different nonces and cannot be confused.