# crypto-cipher ## Purpose AEAD cipher primitives for the Suaveplan monorepo: AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305, and a streaming-AEAD layer that frames an arbitrary `AsyncIterable` source under per-chunk authentication. Per-primitive selector layout enables flipping the default backend from noble to a native engine without changing any caller imports. Nonces are caller-supplied — primitives never generate randomness internally. ## Requirements ### Requirement: Universal AEAD primitive surface `@suaveplan/crypto-cipher` SHALL expose a stable public API for the AEAD primitives listed below, independent of the underlying implementation source. #### Scenario: AEAD primitives available - **WHEN** a consumer imports from `@suaveplan/crypto-cipher` - **THEN** `aes256gcm`, `chacha20poly1305`, `xchacha20poly1305` are exported - **AND** each primitive exposes `encrypt(key, nonce, plaintext, aad?)` and `decrypt(key, nonce, ciphertext, aad?)` returning / consuming `Uint8Array` - **AND** each primitive is also exported from the subpath `@suaveplan/crypto-cipher/` for tree-shaking ### Requirement: Per-primitive replaceability Every AEAD in `@suaveplan/crypto-cipher` SHALL have both a vendor-forked `noble` implementation and a `native` implementation slot, selected per-primitive at build time via the root `crypto.config.ts`. #### Scenario: Every AEAD has both impl files - **WHEN** the package is inspected - **THEN** every primitive id has both `src/primitives//impl/.noble.ts` and `src/primitives//impl/.native.ts` - **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('')` ### Requirement: Vendored noble source (Phase 0.5) From Phase 0.5 onward, the `noble` implementation for every AEAD SHALL come from a vendor-fork of `@noble/ciphers` stored inside this package, not from the `@noble/ciphers` npm package. #### Scenario: No external runtime dependency on @noble/ciphers (post-0.5) - **WHEN** `packages/universal/crypto-cipher/package.json` is inspected after Phase 0.5 - **THEN** `@noble/ciphers` does not appear in `dependencies` or `peerDependencies` - **AND** `src/vendored/` contains the forked source with its original LICENSE preserved - **AND** `NOBLE_UPSTREAM.md` records the pinned upstream commit SHA and sync date ### Requirement: Dangerous-tier CI gate Every primitive in this package belongs to the **dangerous tier**. An AEAD's default SHALL NOT be changed from `'noble'` to `'native'` in `crypto.config.ts` unless the flip PR demonstrates all gates required by `crypto-hash`, **plus** a third-party cryptographic audit sign-off covering the specific native implementation being flipped. #### Scenario: Audit required to flip - **WHEN** a PR modifies `crypto.config.ts` to change an AEAD entry from `'noble'` to `'native'` - **THEN** the flip PR template requires an audit report (PDF + auditor signature) attached to the PR - **AND** the audit report references the exact commit SHA of the native impl being flipped ### Requirement: Constant-time block-cipher and MAC implementation Native AES implementations SHALL be bitsliced or use hardware intrinsics — table-lookup AES is prohibited. Native Poly1305 and GHASH implementations SHALL NOT contain data-dependent branches. #### Scenario: Constant-time lint enforces this - **WHEN** any `impl/*.native.ts` in this package is modified - **THEN** `scripts/ct-lint.ts` scans for prohibited patterns (secret-indexed array access, data-dependent branches, early returns on tag comparison) - **AND** any match fails CI with a line-level diagnostic ### Requirement: Nonce handling is the caller's responsibility Primitives in this package SHALL NOT generate nonces internally. All nonce inputs are supplied by the caller. #### Scenario: No internal random nonce - **WHEN** `encrypt(...)` is invoked - **THEN** no call to a CSPRNG is made inside the primitive - **AND** the nonce parameter is required and validated for length ### Requirement: Streaming AEAD encryption / decryption The package SHALL expose `encryptStreamAead(source, options)` and `decryptStreamAead(source, options)` async generators that consume a `Uint8Array` input stream chunk by chunk, attach a per-chunk authentication tag, and emit the encrypted (or decrypted) chunk stream. Each chunk SHALL use a per-chunk nonce derived deterministically from a base nonce plus a 32-bit chunk counter so the entire stream binds to a single key without nonce reuse. #### Scenario: round-trip preserves bytes - **WHEN** `encryptStreamAead(plaintext, { algorithm, key, baseNonce })` is consumed and its output is fed to `decryptStreamAead(ciphertext, { algorithm, key, baseNonce })` - **THEN** the concatenated output equals the original plaintext byte-for-byte #### Scenario: tag mismatch detected on tampered chunk - **WHEN** any byte of any encrypted chunk's tag is flipped before decryption - **THEN** `decryptStreamAead` throws a `SuaveplanError` with code `CRYPTO_CIPHER.STREAM_TAG_MISMATCH` and `context.chunkIndex` points at the tampered chunk #### Scenario: chunk-counter overflow rejected - **WHEN** the chunk counter reaches `2^32` - **THEN** the next chunk emission throws `CRYPTO_CIPHER.STREAM_OUT_OF_ORDER` with `context.counter === 2 ** 32` **Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"round-trips a multi-chunk plaintext through encryptStreamAead and decryptStreamAead"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"throws STREAM_TAG_MISMATCH when a chunk tag is flipped"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"throws STREAM_OUT_OF_ORDER on chunk-counter overflow"` --- ### Requirement: AbortSignal propagation through streaming AEAD The package SHALL honour an optional `AbortSignal` passed via `EncryptStreamAeadOptions.signal` and `DecryptStreamAeadOptions.signal`. When the signal is aborted mid-stream the generator SHALL flush the in-flight chunk, release any pending cipher state, and throw `SuaveplanError` with code `CRYPTO_CIPHER.STREAM_ABORTED`. #### Scenario: abort mid-stream rejects with STREAM_ABORTED - **WHEN** an `AbortController` is signalled while `encryptStreamAead` is mid-iteration - **THEN** the next `await generator.next()` rejects with `CRYPTO_CIPHER.STREAM_ABORTED` and `context.chunksProcessed` reflects the count emitted before abort **Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts::"aborts mid-stream and rejects with STREAM_ABORTED"` --- ### Requirement: AES-GCM streaming engine adapter The package SHALL expose `aesGcmStreamingEngine: StreamingAeadEngine` that wraps the existing `aes-256-gcm` primitive. Per-chunk nonce SHALL be a 12-byte big-endian concatenation of an 8-byte caller-supplied prefix and a 4-byte chunk counter (RFC 5116 §3.2 compatible). The engine SHALL reject `baseNonce.length !== 8` with `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE`. #### Scenario: 8-byte base nonce accepted - **WHEN** `aesGcmStreamingEngine.encrypt({ baseNonce: 8-byte prefix, key, plaintext, counter: 0 })` is called - **THEN** the produced nonce equals `baseNonce ‖ counter_be32(0)` (12 bytes total) #### Scenario: wrong-length base nonce rejected - **WHEN** `baseNonce.length === 12` - **THEN** the engine throws `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE` with `context.expectedLength === 8` and `context.actualLength === 12` **Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.test.ts::"derives 12-byte nonce from 8-byte base + counter"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.spec-conformance.test.ts::"rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE"` --- ### Requirement: XChaCha20-Poly1305 streaming engine adapter The package SHALL expose `xchacha20StreamingEngine: StreamingAeadEngine` that wraps the existing `xchacha20-poly1305` primitive. Per-chunk nonce SHALL be a 24-byte big-endian concatenation of a 20-byte caller-supplied prefix and a 4-byte chunk counter. The engine SHALL reject `baseNonce.length !== 20` with `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE`. #### Scenario: 20-byte base nonce accepted - **WHEN** `xchacha20StreamingEngine.encrypt({ baseNonce: 20-byte prefix, key, plaintext, counter: 5 })` is called - **THEN** the produced nonce equals `baseNonce ‖ counter_be32(5)` (24 bytes total) #### Scenario: wrong-length base nonce rejected - **WHEN** `baseNonce.length === 24` - **THEN** the engine throws `CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE` with `context.expectedLength === 20` and `context.actualLength === 24` **Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.test.ts::"derives 24-byte nonce from 20-byte base + counter"` AND `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.spec-conformance.test.ts::"rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE"` --- ### Requirement: StreamingAeadEngine contract The package SHALL re-export the `StreamingAeadEngine` interface (and the four supporting interfaces — `StreamingChunkInput`, `StreamingChunkTag`, `StreamingHeader`, `EncryptStreamAeadOptions`, `DecryptStreamAeadOptions`) from `@suaveplan/types`. The canonical home of these types is `@suaveplan/types`; `crypto-cipher` re-exports them so existing consumers' `import { StreamingAeadEngine } from "@suaveplan/crypto-cipher"` continues to compile unchanged. Both built-in engines (AES-GCM, XChaCha20-Poly1305) SHALL satisfy the `@suaveplan/types::StreamingAeadEngine` interface and SHALL be substitutable for any caller-supplied engine. The package SHALL retain its runtime-only exports — `STREAMING_AEAD_MAGIC` (4-byte magic), `STREAMING_AEAD_VERSION` (number), `STREAMING_AEAD_MAX_COUNTER` (number), `streamingAeadHeaderLength()` (function) — locally because `@suaveplan/types` is types-only and cannot host runtime values. #### Scenario: caller-supplied engine round-trips identically - **WHEN** a custom `StreamingAeadEngine` implementation is passed to `encryptStreamAead` and the same instance to `decryptStreamAead` - **THEN** the round-trip preserves bytes byte-for-byte (validated against an in-test spy engine that records every call) #### Scenario: type re-export is structurally equivalent to canonical home - **WHEN** a consumer imports `type StreamingAeadEngine` from `@suaveplan/crypto-cipher` and another imports it from `@suaveplan/types` - **THEN** the TypeScript compiler treats them as the identical type — no structural-equality cast is required **Verified by:** `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"StreamingAeadEngine contract is stable for custom engines"`