Files
suaveplan/openspec/specs/crypto-cipher/spec.md
2026-05-27 23:29:53 +01:00

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, 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"