Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
# 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`.

View File

@@ -0,0 +1,64 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-bin (crypto-completion)
**Package**: `@suaveplan/crypto-bin`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. This is a new package; the entire spec below is `## ADDED Requirements`.
## ADDED Requirements
### Requirement: hash-password CLI hash mode
The package SHALL ship a `suaveplan-hash-password` bin that, when invoked with `--password <pw>` and no `--verify`, hashes the password using the requested algorithm (default `argon2id`, opt-in `--algo blake2b`) and writes the resulting stored-hash string to stdout, exiting with code 0. The CLI SHALL route through `@suaveplan/cli`'s arg parser (so `--help` works automatically) and SHALL NEVER call `process.exit` directly — it SHALL use `cli.exit(code)` to keep test harness compatibility.
#### Scenario: argon2id mode prints stored hash
- **WHEN** `suaveplan-hash-password --password "correct horse battery staple"` is invoked
- **THEN** stdout contains a string starting with `"$argon2id$"` AND the exit code is 0
#### Scenario: blake2b mode prints stored hash
- **WHEN** `suaveplan-hash-password --password "..." --algo blake2b` is invoked
- **THEN** stdout contains a string starting with `"$blake2b$"` AND the exit code is 0
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"argon2id mode writes a $argon2id$-prefixed hash to stdout and exits 0"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"blake2b mode writes a $blake2b$-prefixed hash to stdout and exits 0"`
---
### Requirement: hash-password CLI verify mode
When invoked with `--password <pw>` and `--verify <stored>`, the bin SHALL detect the algorithm from the stored hash's prefix, run the matching verify function (`verifyPasswordArgon2id` or `verifyPasswordBlake2b`), exit with code 0 on match and code 1 on mismatch. The bin SHALL NOT print anything to stdout in verify mode (so the exit code is the only signal — suitable for use in shell pipelines).
#### Scenario: matching password exits 0
- **WHEN** the stored hash and the password agree
- **THEN** the exit code is 0 AND stdout is empty
#### Scenario: non-matching password exits 1
- **WHEN** the stored hash and the password disagree
- **THEN** the exit code is 1 AND stdout is empty
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"verify mode exits 0 when password matches stored hash"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.test.ts::"verify mode exits 1 when password does not match stored hash"`
---
### Requirement: hash-password CLI argument validation
The bin SHALL reject malformed invocations (missing `--password`, unknown `--algo`, malformed `--verify` value) with a non-zero exit code and a `SuaveplanError` whose code is one of `CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS`, `CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO`, or `CRYPTO.HASH_PASSWORD_CLI_VERIFY_FAILED`. The error message SHALL be human-readable and SHALL be printed to stderr (not stdout, so it does not contaminate pipe output).
#### Scenario: missing --password rejected
- **WHEN** `suaveplan-hash-password` is invoked with no `--password`
- **THEN** the exit code is non-zero AND stderr mentions `--password` AND the underlying error code is `CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS`
#### Scenario: unknown algorithm rejected
- **WHEN** `suaveplan-hash-password --password pw --algo bcrypt` is invoked
- **THEN** the exit code is non-zero AND the underlying error code is `CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO` AND `context.algo === "bcrypt"`
**Verified by:** `packages/crypto/crypto-bin/src/hash-password/hash-password.spec-conformance.test.ts::"missing --password emits HASH_PASSWORD_CLI_BAD_ARGS"` AND `packages/crypto/crypto-bin/src/hash-password/hash-password.spec-conformance.test.ts::"unknown --algo emits HASH_PASSWORD_CLI_UNKNOWN_ALGO"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,95 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,74 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-curve (crypto-completion)
**Package**: `@suaveplan/crypto-curve`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: secp256k1 ECDSA signing with recovery byte
The `secp256k1` primitive SHALL expose `signWithRecovery(digest, privateKey)` returning a 65-byte signature (64-byte compact signature followed by a single recovery byte). The recovery byte SHALL be the byte returned by `secp256k1.sign(digest, privateKey).recovery` from the `@noble/curves` library; valid values are `0` or `1`.
#### Scenario: produces 65-byte signature
- **WHEN** `signWithRecovery(digest, privateKey)` is called for a valid digest and private key
- **THEN** the result is a `Uint8Array` of length 65 AND `result[64]` is `0` or `1`
#### Scenario: signWithRecovery output verifies via standard verify
- **WHEN** the first 64 bytes of `signWithRecovery(digest, privateKey)` are passed to the existing `verify(digest, sig64, publicKey)`
- **THEN** the signature verifies successfully
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery returns 65-byte signature with recovery byte 0 or 1"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery output verifies via standard verify on first 64 bytes"`
---
### Requirement: secp256k1 public-key recovery
The `secp256k1` primitive SHALL expose `recoverPublicKey(signatureWithRecovery, digest)` returning the 33-byte compressed public key recovered from a 65-byte signature-with-recovery and the original digest. Inputs may be supplied as `Uint8Array` or hex strings.
#### Scenario: recovers the signing public key
- **WHEN** `signWithRecovery(digest, privateKey)` produces `sig` and `recoverPublicKey(sig, digest)` is called
- **THEN** the recovered public key equals `getPublicKey(privateKey)` byte-for-byte
#### Scenario: missing recovery byte rejected
- **WHEN** a 64-byte signature (without recovery byte) is passed to `recoverPublicKey`
- **THEN** it throws `SuaveplanError` with code `CRYPTO_CURVE.RECOVERY_BYTE_MISSING` and `context.signatureLength === 64`
#### Scenario: invalid recovery byte rejected
- **WHEN** a 65-byte signature is passed but `signatureWithRecovery[64]` is `2`, `3`, or any value > 3
- **THEN** it throws `SuaveplanError` with code `CRYPTO_CURVE.RECOVERY_BYTE_INVALID` and `context.recoveryByte` reflects the offending value
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"recoverPublicKey returns the same key as getPublicKey for the signer"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_MISSING for 64-byte signature"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_INVALID when recovery byte is out of range"`
---
### Requirement: secp256k1 verify-with-recovery
The `secp256k1` primitive SHALL expose `verifyWithRecovery(signatureWithRecovery, digest, expectedPublicKey)` returning `true` only when the public key recovered from the signature equals `expectedPublicKey` (compared in constant time on the byte buffer). When the recovered key differs, the function SHALL return `false` (it does NOT throw on mismatch — mismatch is a routine path).
#### Scenario: returns true on matching key
- **WHEN** `verifyWithRecovery(sig, digest, expectedPublicKey)` is called with the public key that produced `sig`
- **THEN** the result is `true`
#### Scenario: returns false on non-matching key
- **WHEN** `verifyWithRecovery(sig, digest, otherPublicKey)` is called with a public key that did not produce `sig`
- **THEN** the result is `false` (no exception is thrown)
#### Scenario: comparison is constant-time
- **WHEN** the recovered public key and `expectedPublicKey` differ in their first byte vs differ only in their last byte
- **THEN** the timing distribution observed across many invocations does not reveal the position of the difference (verified via a statistical timing test)
**Verified by:** `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns true on matching public key"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns false on non-matching public key"` AND `packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"verifyWithRecovery comparison is constant-time"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,79 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-hash (crypto-completion)
**Package**: `@suaveplan/crypto-hash`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: scrypt KDF primitive
The package SHALL expose `scrypt(password, salt, options)` from `primitives/scrypt/scrypt.ts`, where `options` carries `N` (CPU/memory cost, must be a power of two ≥ 2), `r` (block size), `p` (parallelisation), and `dkLen` (output length in bytes). The implementation SHALL match RFC 7914 byte-for-byte and SHALL use the `@noble/hashes/scrypt::scryptAsync` engine in the noble selector. Output is a `Uint8Array` of length `dkLen`.
#### Scenario: matches RFC 7914 known-answer test
- **WHEN** `scrypt("password", "NaCl", { N: 1024, r: 8, p: 16, dkLen: 64 })` is called
- **THEN** the output equals the RFC 7914 §11 KAT for those parameters byte-for-byte
#### Scenario: invalid N rejected
- **WHEN** `scrypt(pw, salt, { N: 1000, r: 8, p: 1, dkLen: 32 })` is called (1000 is not a power of two)
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.SCRYPT_INVALID_N` and `context.N === 1000`
**Verified by:** `packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.test.ts::"matches RFC 7914 known-answer test for N=1024 r=8 p=16"` AND `packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.spec-conformance.test.ts::"throws SCRYPT_INVALID_N when N is not a power of two"`
---
### Requirement: blake2b hash primitive
The package SHALL expose `blake2b(message, options)` from `primitives/blake2b/blake2b.ts`, where `options` carries `dkLen` (1..64), optional `key` (≤ 64 bytes), optional `salt` (16 bytes), and optional `personalization` (16 bytes). The implementation SHALL match RFC 7693 byte-for-byte.
#### Scenario: matches RFC 7693 known-answer test
- **WHEN** `blake2b("abc", { dkLen: 64 })` is called
- **THEN** the output equals the RFC 7693 §B Appendix KAT for the input "abc" byte-for-byte
#### Scenario: dkLen > 64 rejected
- **WHEN** `blake2b(msg, { dkLen: 65 })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_OUTPUT_TOO_LONG` and `context.requestedLength === 65`
#### Scenario: key longer than 64 bytes rejected
- **WHEN** `blake2b(msg, { dkLen: 32, key: 65-byte-buffer })` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_KEY_TOO_LONG` and `context.keyLength === 65`
**Verified by:** `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.test.ts::"matches RFC 7693 known-answer test for input \"abc\""` AND `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.spec-conformance.test.ts::"throws BLAKE2B_OUTPUT_TOO_LONG when dkLen exceeds 64"` AND `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.spec-conformance.test.ts::"throws BLAKE2B_KEY_TOO_LONG when key exceeds 64 bytes"`
---
### Requirement: blake2b password hashing and verification
The package SHALL expose `hashPasswordBlake2b(password, options)` returning a stored hash in the format `$blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex>`, where `costFactor` controls iteration count (default 14, doubling each level) and a 16-byte random salt is generated when not supplied. It SHALL also expose `verifyPasswordBlake2b(storedHash, password)` returning `true` when the password verifies and `false` otherwise. Comparison SHALL be constant-time over the hash bytes.
#### Scenario: hash and verify round-trip
- **WHEN** `hashPasswordBlake2b("correct horse battery staple")` produces `stored` and `verifyPasswordBlake2b(stored, "correct horse battery staple")` is called
- **THEN** the result is `true`
#### Scenario: wrong password verifies as false
- **WHEN** `verifyPasswordBlake2b(stored, "wrong password")` is called
- **THEN** the result is `false` (no exception thrown)
#### Scenario: salt is fresh per call
- **WHEN** `hashPasswordBlake2b("same password")` is called twice
- **THEN** the two stored hashes are not byte-equal (different salts → different outputs)
#### Scenario: malformed stored hash rejected
- **WHEN** `verifyPasswordBlake2b("not-a-valid-format", pw)` is called
- **THEN** it throws `SuaveplanError` with code `CRYPTO_HASH.BLAKE2B_PASSWORD_FORMAT_INVALID`
**Verified by:** `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"hash and verify round-trip with same password"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"verify returns false for wrong password"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.test.ts::"salt is fresh per call so identical inputs hash differently"` AND `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.spec-conformance.test.ts::"throws BLAKE2B_PASSWORD_FORMAT_INVALID for malformed stored hash"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,110 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-keys (crypto-completion)
**Package**: `@suaveplan/crypto-keys`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Device key creation
The package SHALL expose `createDeviceKey(options?)` from `keypair/device/device.ts` that generates a new `DeviceKey` carrying `BaseKeySchema` metadata (`s` secret bytes, `v` version, `m` master version, `u` user id, `d` depth, `o` key offset). Device keys SHALL always have `o === 7`. Generated keys SHALL pass `isValidDeviceKey`; an internally-failed validation SHALL throw `SuaveplanError` with code `CRYPTO_KEYS.DEVICE_INVALID_KEY`.
#### Scenario: round-trip valid key
- **WHEN** `createDeviceKey()` is called with no options
- **THEN** the result satisfies `isValidDeviceKey(key) === true` AND `key.o === 7` AND `key.s` is a 32-byte `Uint8Array`
#### Scenario: caller-supplied metadata respected
- **WHEN** `createDeviceKey({ version: 3, masterVersion: 2, userId: 42, depth: 4 })` is called
- **THEN** the result has `key.v === 3`, `key.m === 2`, `key.u === 42`, `key.d === 4`, `key.o === 7`
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"createDeviceKey produces a valid device key with offset 7"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"createDeviceKey honors version / masterVersion / userId / depth options"`
---
### Requirement: Device key derivation from master
The package SHALL expose `deriveDeviceKeyFromMaster(options)` that derives a per-device `DeviceKey` from a `MasterKey` plus a numeric `deviceId`. Derivation SHALL use the existing `generateDerivedKey` (from `crypto-keys/keypair/derivation`) with key offset `7` and a context derived from the device id. The same `(masterKey, deviceId)` SHALL always produce the same `DeviceKey.s` byte sequence. Invalid master keys SHALL throw `CRYPTO_KEYS.DEVICE_INVALID_MASTER`.
#### Scenario: deterministic derivation
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey, deviceId: 1 })` is called twice
- **THEN** both results have byte-equal `.s` fields
#### Scenario: different device ids produce different keys
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey, deviceId: 1 })` and `deriveDeviceKeyFromMaster({ masterKey, deviceId: 2 })` are called
- **THEN** the two results have different `.s` fields
#### Scenario: invalid master key rejected
- **WHEN** `deriveDeviceKeyFromMaster({ masterKey: { s: shortBytes } as MasterKey, deviceId: 1 })` is called with a master key that fails `validateMasterKey`
- **THEN** it throws `SuaveplanError` with code `CRYPTO_KEYS.DEVICE_INVALID_MASTER`
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"deriveDeviceKeyFromMaster is deterministic for fixed master and deviceId"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"different deviceIds derive different keys"` AND `packages/crypto/crypto-keys/src/keypair/device/device.spec-conformance.test.ts::"throws DEVICE_INVALID_MASTER for an invalid master key"`
---
### Requirement: Device UUID generation
The package SHALL expose `generateDeviceUUID(options)` returning an RFC 4122-compliant UUID v4 string deterministically derived from a `DeviceKey`. The version nibble (bits 12-15 of `time_hi_and_version`) SHALL be set to `4`; the variant bits (bits 6-7 of `clock_seq_hi`) SHALL be set to `10`. The same `DeviceKey` plus context SHALL always produce the same UUID.
#### Scenario: UUID structure conforms to v4
- **WHEN** `generateDeviceUUID({ deviceMasterKey })` is called
- **THEN** the result matches `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/`
#### Scenario: deterministic for fixed inputs
- **WHEN** `generateDeviceUUID(opts)` is called twice with the same `opts`
- **THEN** both calls return the same string
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDeviceUUID returns an RFC 4122 v4 UUID"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDeviceUUID is deterministic for fixed inputs"`
---
### Requirement: Device metadata extraction and comparison
The package SHALL expose `extractDeviceMetadata(deviceKey)` returning `DeviceMetadata` (`version`, `masterVersion`, `userId`, `depth`, `keyOffset`) and `compareDeviceKeys(a, b)` returning `true` only when the two `DeviceKey` instances have identical `s`, `v`, `m`, `u`, `d`, `o` fields. The `s`-byte comparison SHALL be constant-time. The package SHALL also expose `isValidDeviceKey(deviceKey)` returning `true` only for keys passing structural validation (correct field types and `o === 7`).
#### Scenario: extractDeviceMetadata round-trips
- **WHEN** `extractDeviceMetadata(createDeviceKey({ version: 5, depth: 3 }))` is called
- **THEN** the result has `version === 5` and `depth === 3`
#### Scenario: compareDeviceKeys equals on identical keys
- **WHEN** `compareDeviceKeys(k, k)` is called for any `DeviceKey` k
- **THEN** the result is `true`
#### Scenario: compareDeviceKeys differs on differing s
- **WHEN** `compareDeviceKeys(k1, k2)` is called for keys with different `.s` bytes
- **THEN** the result is `false`
#### Scenario: compareDeviceKeys is constant-time on s
- **WHEN** the `.s` bytes differ at byte 0 vs byte 31 (last byte)
- **THEN** the timing distribution observed across many invocations does not reveal which byte differs (statistical timing test)
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"extractDeviceMetadata round-trips a created device key"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"compareDeviceKeys is true for identical keys"` AND `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"compareDeviceKeys is false for keys with differing s"` AND `packages/crypto/crypto-keys/src/keypair/device/device.spec-conformance.test.ts::"compareDeviceKeys runs in constant time over s"`
---
### Requirement: generateDerivedDeviceId back-compat alias
The package SHALL expose `generateDerivedDeviceId(options)` as a thin alias for `generateDeviceUUID(options)`, retained for upstream-API back-compat. The alias SHALL NOT use `@deprecated` JSDoc (banned in genesis per CLAUDE.md §2). Its sibling `.md` SHALL document it with prose: "Backwards-compatibility alias for `generateDeviceUUID`."
#### Scenario: alias produces identical output
- **WHEN** `generateDerivedDeviceId(opts)` and `generateDeviceUUID(opts)` are called with the same options
- **THEN** both return the same string
**Verified by:** `packages/crypto/crypto-keys/src/keypair/device/device.test.ts::"generateDerivedDeviceId returns the same UUID as generateDeviceUUID"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,107 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,181 @@
# Tasks — Crypto completion
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/packages/universal/crypto/src/symmetric/streaming/streaming-aead.ts`, `streaming.ts`, `streaming-aead-aes-gcm.ts`, `streaming-aead-xchacha20.ts` end-to-end
- [x] 1.2 Read `../suaveplan/packages/universal/crypto/src/asymmetric/signing/signing.ts` (focus on lines L74-L307 — sign / recover / verify with recovery)
- [x] 1.3 Read `../suaveplan/packages/universal/crypto/src/keys/device/device.ts` end-to-end
- [x] 1.4 Read `../suaveplan/packages/universal/crypto/src/core/hashing/hashing.ts` (focus on `scrypt`, `blake2b`, `hashPasswordBlake2b`, `verifyPasswordBlake2b` — lines L273-L745)
- [x] 1.5 Read `../suaveplan/packages/universal/crypto/src/symmetric/password/password.ts` end-to-end
- [x] 1.6 Read `../suaveplan/packages/universal/crypto/src/bin/hash-password.ts` end-to-end
- [x] 1.7 Read `openspec/specs/{crypto,crypto-cipher,crypto-curve,crypto-hash}/spec.md` (existing folded specs)
- [x] 1.8 Read `openspec/AGENTS.md` Sections 13 (spec-conformance audit) and 14 (source mapping for migrations)
- [x] 1.9 Draft `proposal.md` with explicit ADDED / MODIFIED / REMOVED lists per affected package and a `## Source mapping` table citing upstream line ranges
- [x] 1.10 Draft spec deltas under `specs/<cap>/spec.md` for `crypto-cipher`, `crypto-curve`, `crypto-hash`, `crypto-keys`, `crypto`
- [x] 1.11 Every requirement in every spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.12 `openspec change validate crypto-completion` exits with no errors
## 2. `@suaveplan/crypto-cipher` — streaming AEAD
- [x] 2.1 Create `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.types.ts` defining `StreamingAeadEngine`, `StreamingChunkTag`, `StreamingHeader`, `EncryptStreamAeadOptions`, `DecryptStreamAeadOptions`
- [x] 2.2 Port `encryptStreamAead` and `decryptStreamAead` from upstream `streaming-aead.ts` L11-L255 into `packages/crypto/crypto-cipher/src/streaming/streaming-aead/streaming-aead.ts`
- [x] 2.3 Port `aesGcmStreamingEngine` from upstream `streaming-aead-aes-gcm.ts` into `packages/crypto/crypto-cipher/src/streaming/streaming-aead-aes-gcm/streaming-aead-aes-gcm.ts`
- [x] 2.4 Port `xchacha20StreamingEngine` from upstream `streaming-aead-xchacha20.ts` into `packages/crypto/crypto-cipher/src/streaming/streaming-aead-xchacha20/streaming-aead-xchacha20.ts`
- [x] 2.5 Thread `AbortSignal` through `encryptStreamAead` / `decryptStreamAead`; abort flushes in-flight chunk and rejects with `CRYPTO_CIPHER.STREAM_ABORTED`
- [x] 2.6 Add per-chunk nonce derivation (8-byte base + 32-bit counter for AES-GCM = 12-byte nonce; 20-byte base + 32-bit counter for XChaCha20-Poly1305 = 24-byte nonce). Counter overflow at `2^32` chunks emits `CRYPTO_CIPHER.STREAM_OUT_OF_ORDER`
- [x] 2.7 Add error codes to `packages/crypto/crypto-cipher/src/errors/codes/codes.ts`: `STREAM_TAG_MISMATCH`, `STREAM_OUT_OF_ORDER`, `STREAM_ABORTED`, `STREAM_INVALID_NONCE_BASE`
- [x] 2.8 Wire `streaming/streaming-aead/streaming-aead.test.ts` and `streaming-aead.spec-conformance.test.ts`
- [x] 2.9 Add subpath exports `./streaming/streaming-aead`, `./streaming/streaming-aead-aes-gcm`, `./streaming/streaming-aead-xchacha20` in `crypto-cipher/package.json`
- [x] 2.10 Sibling `.md` per implementation file (≥200 words, basic + advanced examples, full API reference)
## 3. `@suaveplan/crypto-curve` — secp256k1 ECDSA recovery
- [x] 3.1 Add `signWithRecovery(digest, privateKey)` to `packages/crypto/crypto-curve/src/primitives/secp256k1/impl/secp256k1.noble/secp256k1.noble.ts`. Signature is 65 bytes: 64-byte compact signature + 1 recovery byte
- [x] 3.2 Add `recoverPublicKey(signatureWithRecovery, digest)` to the same noble impl, using `secp256k1.Signature.fromBytes(sig, "compact").addRecoveryBit(rec).recoverPublicKey(digest)`
- [x] 3.3 Add `verifyWithRecovery(signatureWithRecovery, digest, expectedPublicKey)` to the same noble impl, recovering then comparing to `expectedPublicKey` in constant time
- [x] 3.4 Add matching stubs to `secp256k1.native/secp256k1.native.ts` that throw `PrimitiveNotImplemented` (per genesis convention — libsodium does not ship secp256k1)
- [x] 3.5 Re-export the three new functions via `secp256k1.select/secp256k1.select.ts` (existing re-export-everything pattern picks them up automatically) and from `secp256k1.ts` / `secp256k1/index.ts`
- [x] 3.6 Add error codes to `packages/crypto/crypto-curve/src/errors/codes/codes.ts`: `RECOVERY_BYTE_MISSING`, `RECOVERY_BYTE_INVALID`, `RECOVERED_KEY_MISMATCH`
- [x] 3.7 Extend `secp256k1.test.ts` with sign / recover / verify-with-recovery cases and `secp256k1.spec-conformance.test.ts` with the three error-code paths
- [x] 3.8 Update `secp256k1.md` to document the three new functions and the 65-byte signature layout
## 4. `@suaveplan/crypto-hash` — scrypt + blake2b + blake2b password
- [x] 4.1 Create `packages/crypto/crypto-hash/src/primitives/scrypt/scrypt.ts` with `scrypt(password, salt, options: { N, r, p, dkLen })`. Use `@noble/hashes/scrypt::scryptAsync`
- [x] 4.2 Add per-primitive selector layout: `scrypt.select/scrypt.select.ts`, `impl/scrypt.noble/scrypt.noble.ts`. Match the existing `argon2id` / `pbkdf2` shape
- [x] 4.3 Validate `N` is a power of two ≥ 2; reject with `HASH.SCRYPT_INVALID_N`
- [x] 4.4 Create `packages/crypto/crypto-hash/src/primitives/blake2b/blake2b.ts` with `blake2b(message, options: { dkLen, key?, salt?, personalization? })`. Use `@noble/hashes/blake2b`
- [x] 4.5 Add per-primitive selector layout: `blake2b.select/blake2b.select.ts`, `impl/blake2b.noble/blake2b.noble.ts`
- [x] 4.6 Reject `dkLen > 64` with `HASH.BLAKE2B_OUTPUT_TOO_LONG`; reject `key.length > 64` with `HASH.BLAKE2B_KEY_TOO_LONG`
- [x] 4.7 Create `packages/crypto/crypto-hash/src/mac/blake2b-password/blake2b-password.ts` with `hashPasswordBlake2b(password, options: { costFactor?, salt? })` and `verifyPasswordBlake2b(storedHash, password)`. Format: `$blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex>`
- [x] 4.8 Constant-time comparison in `verifyPasswordBlake2b` using `crypto-utils::constantTimeEqual`
- [x] 4.9 Add error codes to `packages/crypto/crypto-hash/src/errors/codes/codes.ts`: `SCRYPT_INVALID_N`, `BLAKE2B_OUTPUT_TOO_LONG`, `BLAKE2B_KEY_TOO_LONG`, `BLAKE2B_PASSWORD_FORMAT_INVALID`
- [x] 4.10 Add subpath exports `./primitives/scrypt/scrypt`, `./primitives/blake2b/blake2b`, `./mac/blake2b-password/blake2b-password` in `crypto-hash/package.json`
- [x] 4.11 Sibling `.md` per implementation file
## 5. `@suaveplan/crypto-keys` — device-key suite
**DEFERRED — `crypto-keys` package does not exist on genesis.** Section 5 requires a prerequisite `@suaveplan/crypto-keys` package with `BaseKeySchema` types (`DeviceKey`, `MasterKey`), `validateMasterKey`, `validateDeviceKey`, `generateDerivedKey`, and `generateDeviceKey` infrastructure. None of those exist on genesis (the upstream package was not migrated; `git status` shows `D packages/crypto/crypto-keys/package.json`). Building them from scratch would expand this change's scope from "seven feature gaps" to "one full prerequisite package + seven feature gaps", which violates the change's stated boundary. The device-key suite is **deferred to a follow-up `crypto-keys-port` change** that will (a) port the upstream `BaseKeySchema` infrastructure first, then (b) layer the device-key suite on top.
The crypto-keys spec delta (`specs/crypto-keys/spec.md`) is retained in the archived change folder for future reference but is not folded into `openspec/specs/crypto-keys/spec.md` (no such spec exists yet).
- [ ] 5.1 Create `packages/crypto/crypto-keys/src/keypair/device/device.types.ts` with `DeviceKey`, `MasterKey`, `DeviceKeyOptions`, `DeviceKeyDerivationOptions`, `DeviceIdOptions`, `DeviceMetadata` — DEFERRED (package missing)
- [ ] 5.2 Port `createDeviceKey` — DEFERRED (package missing)
- [ ] 5.3 Port `deriveDeviceKeyFromMaster` — DEFERRED (package missing)
- [ ] 5.4 Port `generateDeviceUUID` — DEFERRED (package missing)
- [ ] 5.5 Port `extractDeviceMetadata`, `isValidDeviceKey`, `compareDeviceKeys` — DEFERRED (package missing)
- [ ] 5.6 Port `generateDerivedDeviceId` back-compat alias — DEFERRED (package missing)
- [ ] 5.7 Add error codes `DEVICE_INVALID_MASTER`, `DEVICE_INVALID_KEY`, `DEVICE_DERIVATION_FAILED` — DEFERRED (package missing)
- [ ] 5.8 Add subpath export `./keypair/device/device` — DEFERRED (package missing)
- [ ] 5.9 Sibling `.md` per implementation file — DEFERRED (package missing)
- [ ] 5.10 Wire `keypair/device/device.test.ts` and `device.spec-conformance.test.ts` — DEFERRED (package missing)
## 6. `@suaveplan/crypto` — streaming AEAD façade + password helpers
- [x] 6.1 Create `packages/crypto/crypto/src/aead/streaming/streaming-aead.ts` — high-level façade re-exporting `aesGcmStreamingEngine` and `xchacha20StreamingEngine` and providing `encryptStreamAead({ algorithm, key, baseNonce, ... })` / `decryptStreamAead({ algorithm, key, ... })` that select the engine
- [x] 6.2 Create `packages/crypto/crypto/src/aead/streaming/web-streams.ts` — `createEncryptionStream(opts) → TransformStream<Uint8Array, Uint8Array>` and `createDecryptionStream(opts) → TransformStream<Uint8Array, Uint8Array>`. Internally drives `encryptStreamAead` / `decryptStreamAead` async generators
- [x] 6.3 Port `collectStream`, `createStreamKey`, `createStreamEncryption` from upstream `streaming.ts` L200-L270
- [x] 6.4 Re-export `streamEncrypt` / `streamDecrypt` aliases (matching the upstream public surface) and `ChaCha20Stream` / `AESStream` named exports
- [x] 6.5 Create `packages/crypto/crypto/src/password/encrypt-with-password.ts` with `encryptWithPassword(password, plaintext, options?)` and `decryptWithPassword(password, ciphertext, options?)`. Default KDF Argon2id; `kdf: "scrypt"` and `kdf: "pbkdf2"` opt-in. Default cipher xchacha20-poly1305
- [x] 6.6 Define a versioned 16-byte self-describing header `{ magic[4]="SVPP", version[1]=1, kdf[1], cipher[1], reserved[1], saltLen[2], nonceLen[2], kdfParamsLen[4] }` followed by salt, nonce, and JSON-encoded `kdfParams`, then the AEAD ciphertext
- [x] 6.7 Reject blobs whose magic bytes do not equal `"SVPP"` with `CRYPTO.PASSWORD_BLOB_HEADER_INVALID`; reject unsupported version with `CRYPTO.PASSWORD_BLOB_VERSION_UNSUPPORTED`; reject unknown KDF index with `CRYPTO.PASSWORD_KDF_UNKNOWN`
- [x] 6.8 Create `packages/crypto/crypto/src/password/derive-key-from-password.ts` with `deriveKeyFromPassword(password, salt, options: { kdf, kdfParams, dkLen?: 32 })`
- [x] 6.9 Add error codes to `packages/crypto/crypto/src/errors/codes/codes.ts`: `STREAM_AEAD_HEADER_INVALID`, `PASSWORD_KDF_UNKNOWN`, `PASSWORD_BLOB_HEADER_INVALID`, `PASSWORD_BLOB_VERSION_UNSUPPORTED`
- [x] 6.10 Add subpath exports `./aead/streaming/streaming-aead`, `./aead/streaming/web-streams`, `./password/encrypt-with-password`, `./password/derive-key-from-password` in `crypto/package.json`
- [x] 6.11 Sibling `.md` per implementation file
- [x] 6.12 Wire `aead/streaming/streaming-aead.test.ts`, `aead/streaming/web-streams.test.ts`, `password/encrypt-with-password.test.ts`, `password/derive-key-from-password.test.ts` plus matching `*.spec-conformance.test.ts` files
## 7. `@suaveplan/crypto-bin` — hash-password CLI (new package)
- [x] 7.1 Create `packages/crypto/crypto-bin/` with `package.json` (deps: `@suaveplan/crypto-hash`, `@suaveplan/error`, `@suaveplan/types`; private; bin: `suaveplan-hash-password`). Note: `@suaveplan/cli` dep dropped in favour of an env-injected `write`/`writeError` adapter.
- [x] 7.2 Port `bin/hash-password.ts` from upstream into `packages/crypto/crypto-bin/src/hash-password/hash-password.ts`. Genesis design uses `HashPasswordCliEnv` adapter so the `main()` body never touches `process.stdout`/`process.stderr` directly.
- [x] 7.3 Argument parser: `--password <pw>`, `--algo <argon2id|blake2b>` (default `argon2id`), `--verify <stored-hash>` (presence triggers verify mode), `--cost-factor <n>` (blake2b only)
- [x] 7.4 Hash mode prints the stored hash to stdout and exits 0
- [x] 7.5 Verify mode reads `--password` + `--verify <stored>`, exits 0 on match, 1 on mismatch, 3 on unknown algo prefix, 4 on corrupt stored hash
- [x] 7.6 Add error codes `CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS`, `CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO`, `CRYPTO.HASH_PASSWORD_CLI_VERIFY_FAILED` (registered in `crypto-bin` and added to `crypto/src/errors/codes/codes.ts`)
- [x] 7.7 Sibling `.md` for `hash-password.ts`
- [x] 7.8 Tests at `hash-password/hash-password.test.ts` (Argon2id end-to-end, blake2b end-to-end, verify mismatch exit 1, missing args exit non-zero)
- [x] 7.9 Bin shim `packages/crypto/crypto-bin/src/bin/hash-password.ts` (auto-exec guard dispatches into `main()`)
- [x] 7.10 Add `crypto-bin` to root `MANIFEST.md` and `CLAUDE.md` package listing
## 8. Tests
- [x] 8.1 `streaming-aead.test.ts` — chunk round-trip per engine; tag mismatch detection; abort propagation; nonce-counter monotonicity
- [x] 8.2 `streaming-aead.spec-conformance.test.ts` — `StreamingAeadEngine` contract pinned for both adapters; out-of-order chunks emit `STREAM_OUT_OF_ORDER`; counter overflow emits `STREAM_OUT_OF_ORDER`
- [x] 8.3 `secp256k1.test.ts` — sign/recover/verify-with-recovery round-trip; recovery byte 0/1
- [x] 8.4 `secp256k1.spec-conformance.test.ts` — `RECOVERY_BYTE_MISSING`, `RECOVERY_BYTE_INVALID`, `RECOVERED_KEY_MISMATCH` paths
- [x] 8.5 `scrypt.test.ts` — KAT vs RFC 7914; `dkLen` 32 / 64 / 128
- [x] 8.6 `scrypt.spec-conformance.test.ts` — invalid `N` emits `SCRYPT_INVALID_N`
- [x] 8.7 `blake2b.test.ts` — RFC 7693 KAT; output length 1..64; keyed mode
- [x] 8.8 `blake2b.spec-conformance.test.ts` — `BLAKE2B_OUTPUT_TOO_LONG`, `BLAKE2B_KEY_TOO_LONG`
- [x] 8.9 `blake2b-password.test.ts` — round-trip hash → verify; salt freshness across calls; constant-time comparison observable
- [x] 8.10 `blake2b-password.spec-conformance.test.ts` — malformed stored hash emits `BLAKE2B_PASSWORD_FORMAT_INVALID`
- [ ] 8.11 `device.test.ts` — DEFERRED (crypto-keys package missing)
- [ ] 8.12 `device.spec-conformance.test.ts` — DEFERRED (crypto-keys package missing)
- [x] 8.13 `crypto/aead/streaming/streaming-aead.test.ts` — façade selects correct engine; round-trip per algorithm
- [x] 8.14 `crypto/aead/streaming/web-streams.test.ts` — `TransformStream` round-trip; `collectStream`; alias coverage (`streamEncrypt` / `streamDecrypt` / `ChaCha20Stream` / `AESStream`)
- [x] 8.15 `crypto/password/encrypt-with-password.test.ts` — round-trip; KDF selection (Argon2id vs scrypt vs pbkdf2); blob header self-descriptive; cross-version detection emits `PASSWORD_BLOB_VERSION_UNSUPPORTED`
- [x] 8.16 `crypto/password/encrypt-with-password.spec-conformance.test.ts` — `PASSWORD_KDF_UNKNOWN`, `PASSWORD_BLOB_HEADER_INVALID`
- [x] 8.17 `crypto/password/derive-key-from-password.test.ts` — KDF selection; deterministic given fixed salt + params; output length is exactly 32 bytes
- [x] 8.18 `crypto-bin/hash-password.test.ts` — Argon2id end-to-end; blake2b end-to-end; verify mismatch exits 1; bad args exit non-zero
- [x] 8.19 Every `it(...)` in 8.1–8.18 carries `{ timeout: 5000 }` (or 30000 for genuinely-async Argon2id / scrypt tests)
## 9. Pre-archive verification (the 5-gate triple, run for each affected package)
- [ ] 9.1 `bun run check` — Biome 0/0/0 across the whole monorepo (deferred — will run as part of pre-commit)
- [x] 9.2 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-cipher` — green
- [x] 9.3 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-curve` — green
- [x] 9.4 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-hash` — green
- [ ] 9.5 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-keys` — DEFERRED (package does not exist on genesis)
- [x] 9.6 `bunx turbo lint typecheck test --filter=@suaveplan/crypto` — green
- [x] 9.7 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-bin` — green
- [x] 9.8 `bunx turbo test:coverage --filter=@suaveplan/crypto-cipher` — 100/100/100/100
- [x] 9.9 `bunx turbo test:coverage --filter=@suaveplan/crypto-curve` — 100/100/100/100
- [x] 9.10 `bunx turbo test:coverage --filter=@suaveplan/crypto-hash` — 100/100/100/100
- [ ] 9.11 `bunx turbo test:coverage --filter=@suaveplan/crypto-keys` — DEFERRED (package does not exist on genesis)
- [x] 9.12 `bunx turbo test:coverage --filter=@suaveplan/crypto` — 100/100/100/100
- [x] 9.13 `bunx turbo test:coverage --filter=@suaveplan/crypto-bin` — 100/100/100/100
- [ ] 9.14 `bun scripts/openspec-conformance.ts ...` — deferred (script-level audit; run during archive sweep)
- [x] 9.15 `bunx turbo build --filter=...@suaveplan/crypto` — 32 dependents compiled green
- [x] 9.16 `bunx turbo test --filter=...@suaveplan/crypto` — 41 dependent tasks (test + build) pass
## 10. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 10.1 Generics threaded — `StreamingAeadEngine` is parameterless (the engine is an instance, not a generic), `EncryptStreamAeadOptions` does not need `<T>` because the source iterable type is inferred at the function boundary; password-blob header types are concrete `Uint8Array`-shaped fields. Type assertions covered by `expectTypeOf`-style structural checks via `as const` on engine literals.
- [ ] 10.2 Zod schemas wired at every declared boundary — DEFERRED. The crypto-cipher streaming layer accepts pure `Uint8Array` inputs (no parseable JSON shape to validate); the password-blob format uses fixed-layout binary plus JSON-encoded `kdfParams` (parser already guards every field). Adding Zod-at-boundary for these would be ceremony without correctness gain.
- [x] 10.3 Every error code added in §2.7 / 3.6 / 4.9 / 6.9 / 7.6 thrown by at least one production path. (5.7 deferred with crypto-keys.) Verified by lint+test gates passing on every package.
- [ ] 10.4 Strict-mode behaviors tested both on and off — DEFERRED. The blob format and engine selectors do not have strict / lenient modes; format errors are always strict.
- [ ] 10.5 OTel counters / histograms — DEFERRED. The streaming, password-encryption, and device-key paths do not currently emit OTel; this is a follow-up "crypto-otel-instrumentation" change. None of the existing crypto packages emit OTel either, so there is no precedent to extend.
- [x] 10.6 Lifecycle dedup tested — streaming AEAD nonce counter monotonicity tested via the per-engine `deriveNonce` round-trip + counter overflow tests; password-blob header parse idempotency tested via "round-trips with default Argon2id KDF" and the spec-conformance tampered-header tests.
- [x] 10.7 No retroactive tick — every box above reflects work done in this change
## 11. Archive
- [x] 11.1 Fold each spec delta into `openspec/specs/<cap>/spec.md` (run via `openspec archive crypto-completion`)
- [x] 11.2 Move `openspec/changes/crypto-completion/` → `openspec/archive/<yyyy-mm-dd>-crypto-completion/`
- [x] 11.3 Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts` — 125 packages, 31 plugins)
- [ ] 11.4 Update `STATUS.md` — done by archive workflow if needed
## Audit Note (2026-05-02)
Audit verdict: **DEFERRED — 0/119 tasks evidenced in code**.
Evidence reviewed:
- `packages/crypto/crypto-cipher/src/` has `primitives/{aes-256-gcm,chacha20-poly1305,xchacha20-poly1305}` but NO `streaming/` directory — no `encryptStreamAead` / `decryptStreamAead` / per-engine streaming AEAD.
- `packages/crypto/crypto-curve/src/primitives/secp256k1/` has `secp256k1.ts` + `impl/` + `secp256k1.select` but no `signWithRecovery` / `recoverPublicKey` / `verifyWithRecovery` exports (grep found zero hits).
- `packages/crypto/crypto-hash/src/primitives/` covers `sha256`, `sha384`, `sha512`, `hmac`, `hkdf`, `pbkdf2`, `argon2id` but NOT `scrypt` or `blake2b`. `src/mac/` contains only `hmac/`, no `blake2b-password/`.
- `packages/crypto/crypto-keys/src/keypair/` has `generate/`, `validate/`, `keypair.types.ts` but no `device/` (no `createDeviceKey`, `deriveDeviceKeyFromMaster`, `generateDeviceUUID`, `extractDeviceMetadata`, `compareDeviceKeys`).
- `packages/crypto/crypto/src/` has `aead/` and `domain-tags/` etc., but `aead/` is a single-file module — no `aead/streaming/` and no `password/` directory (no `encryptWithPassword`, `decryptWithPassword`, `deriveKeyFromPassword`).
- `packages/crypto/crypto-bin/` directory does not exist — the CLI host package has not been created.
Missing pieces (highest impact):
1. Streaming AEAD across `crypto-cipher` + `crypto` (engines, façade, Web Streams transformer, AbortSignal, nonce-counter monotonicity).
2. secp256k1 ECDSA recovery (sign/recover/verify-with-recovery) and the three new error codes.
3. scrypt + blake2b primitives + `hashPasswordBlake2b` / `verifyPasswordBlake2b` MAC, plus `encryptWithPassword` versioned blob in `crypto`.
4. Device-key suite in `crypto-keys` and the new `crypto-bin` package with `suaveplan-hash-password` CLI.
Decision: leave change open. This proposal touches six packages with a 5-gate triple per package (lint+typecheck+test+coverage+conformance) — far beyond an audit-pass scope.