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

6.3 KiB

Capability Spec Delta: crypto-cipher (crypto-completion)

Package: @suaveplan/crypto-cipher Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

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 define a StreamingAeadEngine interface in streaming-aead.types.ts with encrypt(input: StreamingChunkInput): Promise<StreamingChunkOutput> and decrypt(input: StreamingChunkInput): Promise<Uint8Array> methods, plus nonceBaseLength: number and tagLength: number constants. Both built-in engines (AES-GCM, XChaCha20-Poly1305) SHALL satisfy this interface and SHALL be substitutable for any caller-supplied engine.

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)

Verified by: packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts::"StreamingAeadEngine contract is stable for custom engines"