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

23 KiB
Raw Permalink Blame History

Crypto completion — streaming AEAD, recovery, device keys, scrypt, blake2b, password helpers, hash-password CLI

Packages: @suaveplan/crypto, @suaveplan/crypto-cipher, @suaveplan/crypto-curve, @suaveplan/crypto-hash, @suaveplan/crypto-keys Status: PROPOSED Type: feature + spec-conformance

Why

The 2026-05-02 audit of the genesis crypto stack against ../suaveplan/packages/universal/crypto/src identified seven confirmed feature gaps that the upstream package shipped but the genesis sub-packages do not. None of these are MAJOR architecture changes — they are concrete capabilities (streaming AEAD, ECDSA recovery, device keys, scrypt KDF, blake2b hashing + password helpers, password-driven AEAD wrappers, and the hash-password CLI) that downstream consumers (drive, network, audit-logging, slsa) currently work around or block on. Genesis policy (CLAUDE.md §Workflow rule 5) says "preserve the full public surface during rewrites — trimming is OK ONLY when code is duplicated elsewhere in-repo or can be replaced by an existing @suaveplan/* library", and none of these seven features are duplicated by anything that already landed.

Closing all seven in one coordinated pass is preferable to seven small changes because (a) they share validation utilities and crypto-utils helpers that have to land at the same revision to avoid mid-port circular pins, (b) the streaming AEAD façade in crypto/aead and the engine in crypto-cipher/primitives are linked — adding either alone leaves the public surface incoherent, and (c) the password helpers (encryptWithPassword, deriveKeyFromPassword) sit on top of the new scrypt + blake2b primitives, so they cannot ship until those primitives exist.

The seven gaps:

  1. Streaming AEAD (crypto-cipher + crypto/aead): encryptStreamAead / decryptStreamAead async generators (255 L), aesGcmStreamingEngine and xchacha20StreamingEngine engines (35 L each), createEncryptionStream / createDecryptionStream Web-Streams adapters, streamEncrypt / streamDecrypt legacy aliases, collectStream, createStreamKey, createStreamEncryption (430 L of streaming.ts).
  2. secp256k1 ECDSA recovery (crypto-curve/primitives/secp256k1): signWithRecovery, recoverPublicKey, verifyWithRecovery — all three present in upstream (lines 74, 143, 243 of signing.ts) and absent from the genesis 5-method secp256k1 noble engine.
  3. Device-key suite (crypto-keys/keypair/device): createDeviceKey, deriveDeviceKeyFromMaster, generateDeviceUUID, extractDeviceMetadata, compareDeviceKeys, isValidDeviceKey, plus generateDerivedDeviceId for one-release back-compat (261 L of device.ts).
  4. scrypt KDF (crypto-hash/primitives/scrypt): N/r/p parameter set, salt + outputLength, async via @noble/hashes/scrypt (53 L of hashing.ts lines L273-L325).
  5. blake2b hashing (crypto-hash/primitives/blake2b): variable output length, optional key, plus hashPasswordBlake2b / verifyPasswordBlake2b constant-time wrappers with the parameterised $blake2b$v=1$cf=N$salt$hash format.
  6. encryptWithPassword + deriveKeyFromPassword (crypto/password): the high-level "password → encrypted blob" pattern (Argon2id-derived key + AEAD), 344 L of password.ts.
  7. hash-password CLI bin (new packages/crypto/crypto-bin or crypto/bin/hash-password.ts): a one-shot CLI that hashes a plaintext password to a verifier and exits 0/1 on verify (98 L of bin/hash-password.ts).

What changes

@suaveplan/crypto-cipher

  • ADDED: streaming/streaming-aead.ts — encryptStreamAead and decryptStreamAead async generators with chunk-level header / footer tags, EncryptStreamAeadOptions and DecryptStreamAeadOptions (chunk size, AAD, AbortSignal, optional header callback for chunk-tag attestation).
  • ADDED: streaming/streaming-aead-aes-gcm.ts — aesGcmStreamingEngine: StreamingAeadEngine adapter built on the existing aes-256-gcm primitive; nonce per chunk derived from a 12-byte base nonce + 32-bit counter (RFC 5116-compatible).
  • ADDED: streaming/streaming-aead-xchacha20.ts — xchacha20StreamingEngine: StreamingAeadEngine adapter built on the existing xchacha20-poly1305 primitive; nonce per chunk derived from a 24-byte base nonce + 32-bit counter.
  • ADDED: streaming/streaming-aead.types.ts — StreamingAeadEngine interface, StreamingChunkTag interface, StreamingHeader interface.
  • ADDED: error codes CRYPTO_CIPHER.STREAM_TAG_MISMATCH, CRYPTO_CIPHER.STREAM_OUT_OF_ORDER, CRYPTO_CIPHER.STREAM_ABORTED, CRYPTO_CIPHER.STREAM_INVALID_NONCE_BASE.

@suaveplan/crypto-curve

  • ADDED: primitives/secp256k1/impl/secp256k1.noble/secp256k1.noble.ts exports signWithRecovery(digest, privateKey), recoverPublicKey(signatureWithRecovery, digest), and verifyWithRecovery(signatureWithRecovery, digest, expectedPublicKey). The signature-with-recovery byte layout is the canonical 65-byte form (64-byte compact signature + 1 recovery byte).
  • ADDED: primitives/secp256k1/impl/secp256k1.native/secp256k1.native.ts matching surface (libsodium does not ship secp256k1 — native engine throws CRYPTO_CURVE.PRIMITIVE_NOT_IMPLEMENTED per genesis convention).
  • MODIFIED: secp256k1.select re-exports the three new functions.
  • ADDED: error codes CRYPTO_CURVE.RECOVERY_BYTE_MISSING, CRYPTO_CURVE.RECOVERY_BYTE_INVALID, CRYPTO_CURVE.RECOVERED_KEY_MISMATCH.

@suaveplan/crypto-hash

  • ADDED: primitives/scrypt/scrypt.ts — scrypt(password, salt, options) with N, r, p, dkLen parameters; uses @noble/hashes/scrypt's scryptAsync.
  • ADDED: primitives/scrypt/scrypt.select and primitives/scrypt/impl/scrypt.noble/scrypt.noble.ts matching the existing per-primitive selector layout.
  • ADDED: primitives/blake2b/blake2b.ts — blake2b(message, options) with dkLen (1–64 bytes), optional key, optional salt, optional personalization.
  • ADDED: primitives/blake2b/blake2b.select and primitives/blake2b/impl/blake2b.noble/blake2b.noble.ts.
  • ADDED: mac/blake2b-password/blake2b-password.ts — hashPasswordBlake2b(password, options) and verifyPasswordBlake2b(storedHash, password), using the $blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex> MCF-style format and constant-time comparison.
  • ADDED: error codes CRYPTO_HASH.SCRYPT_INVALID_N, CRYPTO_HASH.BLAKE2B_OUTPUT_TOO_LONG, CRYPTO_HASH.BLAKE2B_KEY_TOO_LONG, CRYPTO_HASH.BLAKE2B_PASSWORD_FORMAT_INVALID.

@suaveplan/crypto-keys

  • ADDED: keypair/device/device.ts — createDeviceKey, deriveDeviceKeyFromMaster, generateDeviceUUID, extractDeviceMetadata, compareDeviceKeys, isValidDeviceKey, plus the generateDerivedDeviceId back-compat alias.
  • ADDED: keypair/device/device.types.ts — DeviceKey, MasterKey, DeviceKeyOptions, DeviceKeyDerivationOptions, DeviceIdOptions, DeviceMetadata interfaces.
  • ADDED: error codes CRYPTO_KEYS.DEVICE_INVALID_MASTER, CRYPTO_KEYS.DEVICE_INVALID_KEY, CRYPTO_KEYS.DEVICE_DERIVATION_FAILED.

@suaveplan/crypto

  • ADDED: aead/streaming/streaming-aead.ts — high-level façade encryptStreamAead / decryptStreamAead that selects the engine (xchacha20-poly1305 or aes-256-gcm) from the opts argument and delegates to crypto-cipher. Re-exports aesGcmStreamingEngine and xchacha20StreamingEngine.
  • ADDED: aead/streaming/web-streams.ts — createEncryptionStream(opts) and createDecryptionStream(opts) returning TransformStream<Uint8Array, Uint8Array> Web-Streams adapters; collectStream(stream), createStreamKey(rng?), createStreamEncryption(opts) helpers; streamEncrypt / streamDecrypt aliases preserved for back-compat.
  • ADDED: password/encrypt-with-password.ts — encryptWithPassword(password, plaintext, options?) and decryptWithPassword(password, ciphertext, options?). Default KDF is Argon2id; opt into scrypt via kdf: "scrypt" in options. Default cipher is xchacha20-poly1305. Output is a self-describing binary header (magic, kdf, kdfParams, cipher, salt, nonce) followed by the ciphertext.
  • ADDED: password/derive-key-from-password.ts — deriveKeyFromPassword(password, salt, options?) returning a 32-byte Uint8Array suitable for use as a symmetric key. KDF selection (argon2id / scrypt / pbkdf2) is via the options bundle.
  • ADDED: error codes CRYPTO.STREAM_AEAD_HEADER_INVALID, CRYPTO.PASSWORD_KDF_UNKNOWN, CRYPTO.PASSWORD_BLOB_HEADER_INVALID, CRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTED.

@suaveplan/crypto-bin (new package)

  • ADDED: a thin CLI package packages/crypto/crypto-bin whose only purpose is to ship the hash-password bin. Per-Suaveplan policy CLI surfaces live in their own package so they do not leak @suaveplan/cli runtime deps into the universal-tier @suaveplan/crypto. Depends on @suaveplan/cli, @suaveplan/crypto-hash, @suaveplan/error. Bin: suaveplan-hash-password. Reads a password from stdin or --password, writes a verifier hash (Argon2id by default, --algo blake2b opt-in) to stdout; with --verify <stored> reads --password and exits 0 on match, 1 on mismatch.

Out of scope

  • Post-quantum signature primitives (Dilithium, SPHINCS+, ML-KEM key-encapsulation) — separate crypto-pqc-* family of sub-packages already discussed in STATUS.md. Adding PQC primitives requires a separate engine adapter substrate that is larger than this entire change.
  • FIDO2 / WebAuthn helpers — already covered by browser-tier deps in the genesis app shells; no upstream parity gap here.
  • HSM / KMS adapters (CloudHSM, AWS KMS, GCP KMS) — operational concern, not a library gap; tracked separately under the audit-logging adapter family.
  • Argon2id parameter migration helper — upstream ships migrateArgon2idParams(oldHash, newOptions) for re-hashing on login. Genesis already routes Argon2id verification through crypto-hash/primitives/argon2id and migration belongs in a future auth change, not in crypto-hash.
  • Modifying the existing Argon2id primitive — the new encryptWithPassword defers to the existing argon2id primitive without changing its signature.
  • crypto-vault integration — crypto-vault already wraps Argon2id-hardened HD derivation; once scrypt lands in crypto-hash, a follow-up change can add a kdf: "scrypt" option to the vault. Out of scope here to keep this change focused on the seven enumerated gaps.

Source mapping

Genesis enterprise/crypto.md is a generic enterprise-quality template (no per-feature requirements), so source mapping cites the upstream package source directly.

Requirement Source file Source lines Notes
encryptStreamAead / decryptStreamAead async generators packages/universal/crypto/src/symmetric/streaming/streaming-aead.ts L11-L255 Verbatim port; thread AbortSignal through
aesGcmStreamingEngine adapter packages/universal/crypto/src/symmetric/streaming/streaming-aead-aes-gcm.ts L9-L35 Verbatim
xchacha20StreamingEngine adapter packages/universal/crypto/src/symmetric/streaming/streaming-aead-xchacha20.ts L9-L35 Verbatim
createEncryptionStream / createDecryptionStream / streamEncrypt / streamDecrypt / collectStream / createStreamKey / createStreamEncryption packages/universal/crypto/src/symmetric/streaming/streaming.ts L23-L430 Verbatim port; preserve ChaCha20Stream / AESStream named exports
signWithRecovery (secp256k1) packages/universal/crypto/src/asymmetric/signing/signing.ts L74-L120 Verbatim
recoverPublicKey (secp256k1) packages/universal/crypto/src/asymmetric/signing/signing.ts L143-L235 Verbatim
verifyWithRecovery (secp256k1) packages/universal/crypto/src/asymmetric/signing/signing.ts L243-L307 Verbatim
createDeviceKey packages/universal/crypto/src/keys/device/device.ts L32-L49 Verbatim
deriveDeviceKeyFromMaster packages/universal/crypto/src/keys/device/device.ts L56-L89 Verbatim
generateDeviceUUID packages/universal/crypto/src/keys/device/device.ts L96-L155 Verbatim
extractDeviceMetadata packages/universal/crypto/src/keys/device/device.ts L157-L181 Verbatim
isValidDeviceKey, compareDeviceKeys packages/universal/crypto/src/keys/device/device.ts L182-L245 Verbatim
generateDerivedDeviceId back-compat alias packages/universal/crypto/src/keys/device/device.ts L249-L260 Replace upstream @deprecated JSDoc with prose ("Backwards-compatibility alias for generateDeviceUUID.") per CLAUDE.md §2
scrypt async KDF packages/universal/crypto/src/core/hashing/hashing.ts L273-L325 Verbatim (delegates to @noble/hashes/scrypt::scryptAsync)
blake2b hash packages/universal/crypto/src/core/hashing/hashing.ts L1-L60 (imports + helpers used) Re-host as a top-level primitive in crypto-hash
hashPasswordBlake2b packages/universal/crypto/src/core/hashing/hashing.ts L617-L666 Verbatim
verifyPasswordBlake2b packages/universal/crypto/src/core/hashing/hashing.ts L672-L745 Verbatim
encryptWithPassword packages/universal/crypto/src/symmetric/password/password.ts L54-L113 Verbatim port; replace PasswordError plain class with SuaveplanError code CRYPTO.PASSWORD_*
deriveKeyFromPassword packages/universal/crypto/src/symmetric/password/password.ts L172-L209 Verbatim
hash-password CLI packages/universal/crypto/src/bin/hash-password.ts L1-L98 Verbatim port; replace direct process.exit with cli.exit(code) from @suaveplan/cli

Requirements with no source line

Requirement Justification
StreamingAeadEngine interface (formal) Upstream uses a duck-typed object; genesis convention is a named interface in a *.types.ts file (see CLAUDE.md §11 "No nested object literals in interfaces").
Error codes mapped to @suaveplan/error (CRYPTO_CIPHER.STREAM_*, CRYPTO_CURVE.RECOVERY_*, CRYPTO_KEYS.DEVICE_*, CRYPTO_HASH.SCRYPT_* / BLAKE2B_*, CRYPTO.PASSWORD_*) Genesis-wide policy: every thrown error routes through @suaveplan/error with a stable code. Upstream throws plain Error / PasswordError.
crypto-bin as its own package Universal-tier @suaveplan/crypto cannot depend on @suaveplan/cli (CLI is browser/server-tier and pulls in @suaveplan/readline). Splitting the CLI out keeps crypto zero-side-effect and tree-shakeable.
encryptWithPassword self-describing binary header Upstream stores salt + ciphertext only; consumers had to know the iteration count externally. Genesis adds a versioned 16-byte header (magic[4] | version[1] | kdf[1] | cipher[1] | reserved[1] | saltLen[2] | nonceLen[2] | kdfParamsLen[4]) so a blob is self-descriptive and rotation-safe.
decryptWithPassword companion to encryptWithPassword Upstream's decryptWithPassword (L115-L160 of password.ts) is included. Listed here only because the proposal text earlier groups gap 6 as one verb; this row exists so reviewers can see both halves are covered.
signWithRecovery / recoverPublicKey / verifyWithRecovery exposed at secp256k1.noble.ts rather than the higher-level signing.ts Upstream's signing.ts is a multi-key façade that pulls BaseKeySchema plumbing genesis hasn't ported. Putting the three functions on the curve primitive (where they belong cryptographically) avoids a circular dep with crypto-keys.

Verification approach

Each spec delta requirement carries a **Verified by:** line pointing at concrete it(...) titles. The test files that will exist after this change ships:

  • packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.test.ts — chunk round-trip per engine; tag mismatch detection; abort propagation
  • packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.spec-conformance.test.ts — StreamingAeadEngine contract pinned for both adapters; nonce-counter monotonicity; out-of-order chunks emit STREAM_OUT_OF_ORDER
  • packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts — extended with sign/recover/verify-with-recovery cases; recovery byte 0/1 round-trip
  • packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts — invalid recovery byte emits RECOVERY_BYTE_INVALID; missing recovery byte emits RECOVERY_BYTE_MISSING; recovered key mismatch emits RECOVERED_KEY_MISMATCH
  • packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.test.ts — KAT round-trip vs upstream RFC 7914 vectors; dkLen 32 / 64 / 128
  • packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.spec-conformance.test.ts — invalid N (non-power-of-two, < 2) emits SCRYPT_INVALID_N
  • packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.test.ts — RFC 7693 KAT round-trip; output length 1..64; keyed mode
  • packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.spec-conformance.test.ts — output > 64 emits BLAKE2B_OUTPUT_TOO_LONG; key > 64 emits BLAKE2B_KEY_TOO_LONG
  • packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts — round-trip hash → verify; salt freshness across calls; constant-time comparison observable via timing-side-channel test
  • packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.spec-conformance.test.ts — malformed stored hash emits BLAKE2B_PASSWORD_FORMAT_INVALID
  • packages/crypto/crypto-keys/src/keypair/device/device.test.ts — createDeviceKey round-trip; deriveDeviceKeyFromMaster deterministic; generateDeviceUUID valid v4; extractDeviceMetadata round-trip; compareDeviceKeys constant-time
  • packages/crypto/crypto-keys/src/keypair/device/device.spec-conformance.test.ts — invalid master emits DEVICE_INVALID_MASTER; corrupt key emits DEVICE_INVALID_KEY
  • packages/crypto/crypto/src/aead/streaming/streaming-aead.test.ts — façade selects correct engine; round-trip per xchacha20-poly1305 and aes-256-gcm
  • packages/crypto/crypto/src/aead/streaming/web-streams.test.ts — Web-Streams round-trip; collectStream aggregates correctly; streamEncrypt / streamDecrypt aliases match the canonical functions
  • packages/crypto/crypto/src/password/encrypt-with-password.test.ts — round-trip encrypt → decrypt; KDF selection (argon2id vs scrypt); blob header self-descriptive (different KDF params produce different headers; reading a v1 blob with a v2 reader emits PASSWORD_BLOB_VERSION_UNSUPPORTED)
  • packages/crypto/crypto/src/password/encrypt-with-password.spec-conformance.test.ts — kdf: "unknown" emits PASSWORD_KDF_UNKNOWN; truncated blob emits PASSWORD_BLOB_HEADER_INVALID
  • packages/crypto/crypto/src/password/derive-key-from-password.test.ts — KDF selection; deterministic given fixed salt + params; output length is exactly 32 bytes
  • packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts — Argon2id mode end-to-end (hash, then verify, exit 0); blake2b mode end-to-end; bad password verify exits 1; missing --password arg emits non-zero exit
  • packages/crypto/crypto-bin/src/hash-password/hash-password.spec-conformance.test.ts — error path emits CRYPTO.HASH_PASSWORD_CLI_* codes (one per known failure mode)

Migration impact

This change is additive — it does not modify any existing public surface. Verification:

  • Existing @suaveplan/crypto consumers (crypto-vault, slsa, audit-logging, auth, streams, bitpacker-crypto, drive-plugin-chunks, data/packer) — none use signWithRecovery / streaming AEAD / device keys / scrypt / blake2b / encryptWithPassword / hash-password CLI today (the symbols don't exist on genesis). New exports therefore have zero callers at land time.
  • Existing crypto-cipher callers (crypto, crypto-sodium) — only consume the three primitive selectors (aes-256-gcm, chacha20-poly1305, xchacha20-poly1305). The new streaming/* subpath is an isolated subtree.
  • Existing crypto-curve callers (crypto) — only consume the four primitive selectors. The three new signWithRecovery / recoverPublicKey / verifyWithRecovery exports do not collide with the existing sign / verify.
  • Existing crypto-hash callers (crypto, crypto-vault, crypto-curve) — consume sha256, sha384, sha512, hmac, hkdf, pbkdf2, argon2id. The new scrypt and blake2b primitives are isolated.
  • Existing crypto-keys callers (crypto-vault, auth) — consume Keychain and keypair/{generate,validate}. The new keypair/device subtree is isolated.

A bunx turbo build typecheck test --filter=...@suaveplan/crypto* sweep runs in §9 of tasks.md to verify all dependents compile and test green before archive.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/crypto to enumerate. For each dependent, check one box:

  • @suaveplan/crypto-sodium — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only the existing AEAD / sign / agree façade; new streaming / password helpers are additive subpaths)
  • @suaveplan/crypto-browser — [ ] tests run / [ ] docs reviewed / [x] no action needed (same — consumes existing AEAD / sign / agree façade)
  • @suaveplan/crypto-vault — [x] tests run / [x] docs reviewed / [ ] no action needed (vault may want to opt into scrypt KDF in a follow-up; verifying it still typechecks is required because it imports crypto-hash directly)
  • @suaveplan/crypto-keys — [x] tests run / [x] docs reviewed / [ ] no action needed (we are adding keypair/device/* to it in this change, so its own tests must run)
  • @suaveplan/auth — [x] tests run / [ ] docs reviewed / [ ] no action needed (consumes crypto-keys, depends on us not breaking the existing keypair/{generate,validate} exports)
  • @suaveplan/audit-logging — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only the AEAD façade for log encryption — new streaming subpath does not collide)
  • @suaveplan/slsa — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only crypto/sign for in-toto attestation signing — signWithRecovery is a new export, not a modification)
  • @suaveplan/streams — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only the existing primitive selectors)
  • @suaveplan/bitpacker-crypto — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes the AEAD façade for packed-bit encryption — new exports do not collide)
  • @suaveplan/drive-plugin-chunks — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes the AEAD façade for at-rest chunk encryption — new streaming subpath is a candidate for a follow-up port, not a regression risk here)
  • @suaveplan/data/packer — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes the AEAD façade only)
  • @suaveplan/crypto-utils — [x] tests run / [ ] docs reviewed / [ ] no action needed (foundation — its own coverage gate must pass after this change; crypto-utils is unchanged but crypto-hash and crypto-cipher consume it, so its tests must still pass)

For the four [x] dependents above (crypto-vault, crypto-keys, auth, crypto-utils), running their full test suite is part of §9 of tasks.md.