11 KiB
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,xchacha20poly1305are exported - AND each primitive exposes
encrypt(key, nonce, plaintext, aad?)anddecrypt(key, nonce, ciphertext, aad?)returning / consumingUint8Array - 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.tsandsrc/primitives/<id>/impl/<id>.native.ts - AND the
native.tsfile is either a working implementation or a stub that throwsPrimitiveNotImplemented('<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.jsonis inspected after Phase 0.5 - THEN
@noble/ciphersdoes not appear independenciesorpeerDependencies - AND
src/vendored/contains the forked source with its original LICENSE preserved - AND
NOBLE_UPSTREAM.mdrecords 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.tsto 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.tsin this package is modified - THEN
scripts/ct-lint.tsscans 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 todecryptStreamAead(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
decryptStreamAeadthrows aSuaveplanErrorwith codeCRYPTO_CIPHER.STREAM_TAG_MISMATCHandcontext.chunkIndexpoints 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_ORDERwithcontext.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
AbortControlleris signalled whileencryptStreamAeadis mid-iteration - THEN the next
await generator.next()rejects withCRYPTO_CIPHER.STREAM_ABORTEDandcontext.chunksProcessedreflects 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_BASEwithcontext.expectedLength === 8andcontext.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_BASEwithcontext.expectedLength === 20andcontext.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
StreamingAeadEngineimplementation is passed toencryptStreamAeadand the same instance todecryptStreamAead - 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 StreamingAeadEnginefrom@suaveplan/crypto-cipherand 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"