Files
2026-05-27 23:29:53 +01:00

7.6 KiB

Capability Spec Delta: crypto (crypto-completion)

Package: @suaveplan/crypto Status: PROPOSED

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

ADDED Requirements

Requirement: High-level streaming AEAD façade

The package SHALL expose encryptStreamAead(opts) and decryptStreamAead(opts) from aead/streaming/streaming-aead.ts that select the engine (xchacha20-poly1305 or aes-256-gcm) from opts.algorithm and delegate to the corresponding StreamingAeadEngine in @suaveplan/crypto-cipher. The package SHALL re-export the named engines aesGcmStreamingEngine and xchacha20StreamingEngine so callers wanting the engine directly do not need to depend on crypto-cipher.

Scenario: facade routes to AES-GCM engine

  • WHEN encryptStreamAead({ algorithm: "aes-256-gcm", key, baseNonce: 8-byte, plaintext }) is called
  • THEN the produced ciphertext decrypts via the standalone aesGcmStreamingEngine exported from this package, byte-for-byte

Scenario: facade routes to XChaCha20-Poly1305 engine

  • WHEN encryptStreamAead({ algorithm: "xchacha20-poly1305", key, baseNonce: 20-byte, plaintext }) is called
  • THEN the produced ciphertext decrypts via the standalone xchacha20StreamingEngine exported from this package, byte-for-byte

Scenario: unknown algorithm rejected

  • WHEN encryptStreamAead({ algorithm: "rot13" as never, ... }) is called
  • THEN it throws SuaveplanError with code CRYPTO.STREAM_AEAD_HEADER_INVALID and context.algorithm === "rot13"

Verified by: packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes aes-256-gcm to aesGcmStreamingEngine" AND packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts::"facade routes xchacha20-poly1305 to xchacha20StreamingEngine" AND packages/crypto/crypto/src/aead/streaming/streaming-aead.spec-conformance.test.ts::"throws STREAM_AEAD_HEADER_INVALID on unknown algorithm"


Requirement: Web-Streams adapters for streaming AEAD

The package SHALL expose createEncryptionStream(opts) → TransformStream<Uint8Array, Uint8Array> and createDecryptionStream(opts) → TransformStream<Uint8Array, Uint8Array> from aead/streaming/web-streams.ts. These SHALL be drop-in pipeThrough targets for any Web-Streams ReadableStream<Uint8Array> source (e.g. Blob.stream(), Response.body, fetch().body). The package SHALL also expose collectStream(stream) → Promise<Uint8Array> (concatenates all chunks), createStreamKey(rng?) → Uint8Array (returns 32 random bytes), and createStreamEncryption(opts) (combined key + base-nonce + initialised stream factory).

Scenario: pipeThrough round-trips a Blob

  • WHEN blob.stream().pipeThrough(createEncryptionStream(opts)).pipeThrough(createDecryptionStream(opts)) is collected via collectStream
  • THEN the collected bytes equal the original blob bytes

Scenario: legacy aliases preserved

  • WHEN streamEncrypt, streamDecrypt, ChaCha20Stream, AESStream are imported
  • THEN all four resolve to defined values that satisfy the documented upstream contract

Verified by: packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"pipeThrough round-trips a multi-chunk source through encrypt then decrypt" AND packages/crypto/crypto/src/aead/streaming/web-streams.test.ts::"streamEncrypt / streamDecrypt / ChaCha20Stream / AESStream aliases resolve and round-trip"


Requirement: Password-based encryption helpers

The package SHALL expose encryptWithPassword(password, plaintext, options?) and decryptWithPassword(password, ciphertext, options?) from password/encrypt-with-password.ts. The options.kdf field SHALL accept "argon2id" (default), "scrypt", or "pbkdf2". The output blob SHALL begin with a versioned 16-byte self-describing header { magic: "SVPP", version: 1, kdf, cipher, reserved, saltLen, nonceLen, kdfParamsLen } followed by salt, nonce, CBOR-encoded kdfParams, then AEAD ciphertext. decryptWithPassword SHALL parse the header to recover all parameters and SHALL NOT require any out-of-band metadata from the caller.

Scenario: round-trip with default Argon2id

  • WHEN encryptWithPassword("correct horse battery staple", plaintext) produces blob and decryptWithPassword("correct horse battery staple", blob) is called
  • THEN the result equals plaintext byte-for-byte

Scenario: round-trip with scrypt KDF

  • WHEN encryptWithPassword(pw, plaintext, { kdf: "scrypt" }) produces blob and decryptWithPassword(pw, blob) is called
  • THEN the result equals plaintext byte-for-byte AND the blob's KDF byte signals scrypt

Scenario: blob header self-describes

  • WHEN the same (password, plaintext) is encrypted twice with different kdfParams
  • THEN the two blobs have different KDF-params bytes in their headers AND both decrypt successfully without the caller passing kdfParams to decryptWithPassword

Scenario: unknown KDF rejected

  • WHEN encryptWithPassword(pw, plaintext, { kdf: "bcrypt" as never }) is called
  • THEN it throws SuaveplanError with code CRYPTO.PASSWORD_KDF_UNKNOWN and context.kdf === "bcrypt"

Scenario: corrupt header rejected

  • WHEN decryptWithPassword(pw, blob) is called with a blob whose first four bytes are not "SVPP"
  • THEN it throws SuaveplanError with code CRYPTO.PASSWORD_BLOB_HEADER_INVALID

Scenario: unsupported version rejected

  • WHEN decryptWithPassword(pw, blob) is called with a blob whose version byte is 99
  • THEN it throws SuaveplanError with code CRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTED and context.version === 99

Verified by: packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with default Argon2id KDF" AND packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"round-trips with scrypt KDF" AND packages/crypto/crypto/src/password/encrypt-with-password.test.ts::"blob header is self-describing across kdfParams variants" AND packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_KDF_UNKNOWN on unknown KDF name" AND packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_HEADER_INVALID on corrupt magic bytes" AND packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts::"throws PASSWORD_BLOB_VERSION_UNSUPPORTED on unknown version byte"


Requirement: deriveKeyFromPassword

The package SHALL expose deriveKeyFromPassword(password, salt, options?) from password/derive-key-from-password.ts. The options SHALL accept kdf: "argon2id" | "scrypt" | "pbkdf2" (default "argon2id"), kdfParams: Record<string, number> (algorithm-specific), and dkLen: number (default 32). The function SHALL return a Uint8Array of length dkLen suitable for direct use as a symmetric AEAD key. Identical (password, salt, kdf, kdfParams) SHALL produce byte-identical output.

Scenario: deterministic given fixed inputs

  • WHEN deriveKeyFromPassword(pw, salt, { kdf: "scrypt", kdfParams: { N: 1024, r: 8, p: 1 } }) is called twice
  • THEN both calls return byte-identical 32-byte Uint8Arrays

Scenario: dkLen honored

  • WHEN deriveKeyFromPassword(pw, salt, { kdf: "argon2id", dkLen: 64 }) is called
  • THEN the result is a 64-byte Uint8Array

Verified by: packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"deterministic for fixed password / salt / params" AND packages/crypto/crypto/src/password/derive-key-from-password.test.ts::"honors dkLen option"