Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
# 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<Uint8Array>` 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/<id>` 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/<id>/impl/<id>.noble.ts` and `src/primitives/<id>/impl/<id>.native.ts`
- **AND** the `native.ts` file is either a working implementation or a stub that throws `PrimitiveNotImplemented('<id>')`
### 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"`
<!-- markdownlint-enable MD024 -->