Initial Commit
This commit is contained in:
157
packages/foundation/flow/streams/src/transforms/crypto/crypto.md
Normal file
157
packages/foundation/flow/streams/src/transforms/crypto/crypto.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# crypto
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## 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
|
||||
|
||||
```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<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
|
||||
|
||||
```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."
|
||||
Reference in New Issue
Block a user