7.1 KiB
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 --helpis 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/<command>.test.ts::"<command> 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=hunter2is 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 jsonis 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(perclipackage) 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 <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 abcis 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
rollentry 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 initproduces a vault, theninspectreads its public bundle, thenverifychecks an envelope produced under that root, thenexport-publicre-emits the public bundle - THEN all four steps succeed end-to-end and the public bundles produced by
root initandexport-publicare byte-equal
Verified by: tests/end-to-end.test.ts::"root-init → inspect → verify → export-public round-trips" AND src/commands/<command>.test.ts::"<command> happy path" AND src/commands/<command>.test.ts::"<command> failure modes" (one per command)