# Capability Spec Delta: crypto-cli (restore-crypto-keychain-packer) **Package**: `@suaveplan/crypto-cli` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: `keychain` binary surface The package SHALL ship a `keychain` binary (declared in `package.json` `bin`) implementing eight subcommands: `root init`, `server init`, `server roll`, `server revoke`, `inspect`, `verify`, `export-public`, `restore`. The CLI is built on `@suaveplan/cli` (argument parser, command router, prompts, output formatters) and `@suaveplan/readline` (TTY input). All cryptographic work goes through `@suaveplan/crypto`; the CLI never imports `@suaveplan/crypto-core` directly. Every subcommand supports `--output json|binary|text` (default `text`), reads/writes from stdin/stdout when paths are `-`, and emits OTel spans for the operation. #### Scenario: every documented subcommand exists and is invokable - **WHEN** `keychain --help` is run - **THEN** the output lists exactly the eight subcommands above with one-line descriptions #### Scenario: stdin/stdout streaming via `-` - **WHEN** `keychain inspect -` is run with a packed public bundle on stdin - **THEN** the inspector reads the bundle from stdin and writes the parsed report to stdout, exit code 0 **Verified by:** `tests/cli-surface.test.ts::"help lists all eight subcommands"` AND `tests/cli-surface.test.ts::"inspect reads from stdin via -"` AND `src/commands/.test.ts::" emits an OTel span"` (one per subcommand) --- ### Requirement: TTY-only password input The package SHALL accept passwords only through (a) the `cli`-package `password` prompt (TTY echo `*`), or (b) the `KEYCHAIN_PASSWORD` environment variable. A `--password` flag MUST be rejected at argument-parse time with `CryptoError` code `CLI.PASSWORD_FLAG_FORBIDDEN` and an exit code of 2 (usage error). The error message points the user at the two supported input paths. This is a credential-handling rule: shell history is a documented credential leak path. #### Scenario: `--password` flag is rejected - **WHEN** `keychain root init --password=hunter2` is invoked - **THEN** the binary exits with code 2 and stderr names the forbidden flag and lists the two allowed alternatives #### Scenario: env-var input works - **WHEN** `KEYCHAIN_PASSWORD=hunter2 keychain root init --output json` is invoked in a non-interactive shell - **THEN** the binary uses the env-var value, exit code 0 #### Scenario: TTY prompt works - **WHEN** the CLI is driven via a fake `InputReader` (per `cli` package) and a password is supplied at the prompt - **THEN** the binary uses the supplied password, exit code 0; the password is never echoed (the test asserts no plaintext appears in the captured TTY transcript) **Verified by:** `tests/no-password-flag.test.ts::"--password flag exits with code 2 and security pointer"` AND `tests/tty-mock.test.ts::"env-var KEYCHAIN_PASSWORD is consumed"` AND `tests/tty-mock.test.ts::"TTY password prompt does not echo plaintext"` --- ### Requirement: Distinct exit codes per failure class The package SHALL exit with distinct, documented exit codes per failure class so operators can branch on them in scripts: | Code | Meaning | |---|---| | 0 | success | | 2 | usage error (bad flag, missing argument, `--password` rejected) | | 10 | bad password (`VAULT.BAD_PASSWORD`) | | 11 | tampered vault (`VAULT.TAMPERED`) | | 12 | expired key (`KEYCHAIN.EXPIRED`) | | 13 | revoked key (`AUTHORITY.REVOKED` / CRL hit) | | 14 | missing trust anchor (`TRUST_ANCHOR.MISSING`) | | 15 | audit-log mismatch (`AUDIT.TAMPERED`) | | 16 | replay rejected (`PACKER.REPLAY_TOO_OLD` / `PACKER.REPLAY_NONCE_SEEN`) | Every other failure exits with code 1. #### Scenario: each documented exit code is reachable - **WHEN** the CLI is driven into each documented failure class - **THEN** the exit code matches the table above **Verified by:** `tests/exit-codes.test.ts::"each documented exit code is produced by its failure class"` --- ### Requirement: Idempotency on state-changing commands The package SHALL accept `--idempotency-key ` on `server roll` and `server revoke`. The CLI maintains a local idempotency ledger at `$XDG_STATE_HOME/suaveplan/keychain/idempotency.json` (or platform equivalent). A repeated call with the same key returns the cached result without re-issuing or re-revoking; the cached result includes the original exit code. The ledger entries expire after 24 hours; expired keys are treated as fresh. #### Scenario: repeated roll with same idempotency key is a no-op - **WHEN** `keychain server roll --idempotency-key abc` is invoked twice in succession - **THEN** the first call succeeds and produces a new server identity; the second call exits 0 with the same output and does not append a second `roll` entry to the audit log **Verified by:** `src/commands/server-roll.test.ts::"idempotency key replays the cached result"` --- ### Requirement: Per-command behaviour Each subcommand SHALL have documented inputs, outputs, and side effects. The full table MUST be captured in `src/commands/COMMANDS.md` and asserted by per-command tests. Every subcommand MUST be invokable with the same flags documented here without runtime drift. | Subcommand | Inputs | Output | Side effects | |---|---|---|---| | `root init` | `--user-id`, password (TTY/env), `--mnemonic` (optional) | encrypted vault + public bundle | writes vault to `--vault-path`, public bundle to `--public-path`; appends audit log if `--audit-log` is supplied | | `server init` | root vault path, root password, `--user-id`, server password | server vault + signed public bundle | writes server vault, server public bundle, appends audit log, no CRL change | | `server roll` | root vault, root password, server vault, server password, `--idempotency-key` | new server vault + new public bundle (version+1) | writes new files, appends CRL entry for prior version, appends audit log | | `server revoke` | root vault, root password, server identity ref, `--reason`, `--idempotency-key` | CRL entry + audit-log entry | appends CRL, appends audit log; no new server identity | | `inspect` | packed public bundle (path or `-`) | parsed report (text/json) | none (read-only) | | `verify` | envelope (path or `-`), `--trust-anchor`, `--crl` | OK / failure with code | none (read-only) | | `export-public` | vault path + password | public-only bundle | writes public bundle | | `restore` | mnemonic (TTY/env), `--user-id`, `--version`, password | restored vault | writes restored vault | #### Scenario: each subcommand round-trips with itself - **WHEN** `root init` produces a vault, then `inspect` reads its public bundle, then `verify` checks an envelope produced under that root, then `export-public` re-emits the public bundle - **THEN** all four steps succeed end-to-end and the public bundles produced by `root init` and `export-public` are byte-equal **Verified by:** `tests/end-to-end.test.ts::"root-init → inspect → verify → export-public round-trips"` AND `src/commands/.test.ts::" happy path"` AND `src/commands/.test.ts::" failure modes"` (one per command)