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

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 --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/<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=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 <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/<command>.test.ts::"<command> happy path" AND src/commands/<command>.test.ts::"<command> failure modes" (one per command)