did-btcr2-js

ADR 080: Keystore Lifecycle, a Confirmed First Passphrase, and Opt-In Dev Keystores

Status: Accepted

Date: 2026-07-08

Branch / PR: feat/cli-home-keystore-lifecycle

References: ADR 052, ADR 077, ADR 079

Context

The encrypted keystore protects each secret key with an independent argon2id + XChaCha20-Poly1305 envelope (keystore/envelope.ts), all opened by one shared passphrase acquired lazily through getPassphrase (config.ts). Three problems in that lifecycle surfaced while preparing a live CRUD workshop, and one of them silently destroyed keys.

  1. The first passphrase is never confirmed, so a typo permanently seals the keystore. buildKeystoreKms wired getPassphrase: () => acquirePassphrase({ passphraseFile }) with no confirm: true. acquirePassphrase has a confirm mode (prompt twice, require a match), but nothing turned it on. The first key generate on a fresh keystore therefore sealed the new key under whatever the operator typed once, with no second entry to catch a slip. If they mistyped, the keystore was now sealed with a passphrase nobody knows, and the key was unrecoverable. This is not hypothetical: it is how four throwaway test keys were lost.

  2. A returning-user typo is just as destructive, and confirm alone does not fix it. Even with confirm on the first key, the second key generate prompts once and seals key 2 under whatever was typed. Because each key carries its own envelope and there was no check that the passphrase matched the one the other keys use, a typo on key 2 sealed it under a divergent passphrase. Later, key 1 opens fine and key 2 fails to decrypt, with no explanation. There was nothing in the file that a candidate passphrase could be checked against before it is used to seal or open a secret.

  3. There is no keystore lifecycle surface and no way to run without a passphrase for throwaway keys. There was no command group to establish, inspect, or re-key the keystore: no init, no status, no change-passphrase. And every key operation was gated on a passphrase even when the keys are disposable testnet material - which for the workshop means every attendee fights the passphrase prompt (or leaks it via BTCR2_KEYSTORE_PASSPHRASE in shell history) before they can create their first DID. The mainnet default must stay encryption-at-rest; the demo needs a sanctioned, loud, testnet-only escape hatch.

Decision

A passphrase verifier makes establishment confirmed and every later use checked

Add a self-describing protection header to the keystore file (the format stays v: 1; the new fields are the keystore’s own description of how its secrets are stored):

The store’s passphrase handling is binary:

  1. Establishing a fresh encrypted keystore (no verifier yet): acquire the passphrase in confirm mode (prompt twice, require a match) and write the verifier in the same locked, atomic flush as the first key. This is the first-seal confirm fix, and it applies whether establishment happens through an explicit keystore init or implicitly through the first key generate.
  2. Using an established encrypted keystore (a verifier is present): acquire the passphrase once, then decrypt the verifier first. A wrong passphrase fails there, loudly (Incorrect passphrase), before any key is sealed or opened - so a returning-user typo can no longer seal a key under a divergent passphrase or silently fail later.

There is deliberately no third “sealed keys but no verifier” state. A keystore always self-describes, and the loader refuses a file that lacks a recognized protection header, that carries sealed keys without a verifier, or whose per-entry secret form (a sealed envelope vs. a plaintext plainSecret) disagrees with its protection mode. Such a file was not written by this CLI: there is no pre-header keystore format to accommodate and no released consumer to be backward-compatible with, so refusing it is correct. This removes an entire class of “which passphrase is this key under” ambiguity, including the concurrent-establish / concurrent-rotate race that ambiguity created: because a sealed-but-unverified keystore cannot exist on disk, set() only ever either establishes (mint and record the verifier, no writer having beaten it) or verifies against the existing one, so a key can never be persisted under a passphrase that diverges from the keystore verifier.

getPassphrase gains an optional { confirm } argument so the store, which alone knows whether it is establishing or reusing, controls when the second prompt happens. confirm is a no-op for the non-interactive sources (BTCR2_KEYSTORE_PASSPHRASE, --passphrase-file), which have nothing to prompt twice.

A keystore command group owns the lifecycle

Opt-in, loudly-marked dev keystores, hard-refused on mainnet

A dev keystore (protection: 'none') stores each secret as plaintext bytes (plainSecret, base64url) instead of an envelope. It never prompts for a passphrase, on read or write. Its file is still created 0600 and still fails closed on loose permissions, and keystore init --dev and keystore status both print a prominent warning that keys are stored unencrypted.

Because plaintext keys are only acceptable for disposable testnet material, the CLI hard-refuses to use a dev keystore for mainnet: any update or deactivate whose DID network is bitcoin, and any create that would generate and seal a new key into a dev keystore on bitcoin, throws before signing or sealing. The refusal is a hard error, not a warning - a plaintext mainnet key is a foot-gun with no legitimate use here. The check reads the keystore’s protection field without decrypting, so it costs nothing and never prompts. Testnet, signet, mutinynet, and regtest are unaffected.

btcr2 init is the one-command happy-path entry point

Add a top-level btcr2 init that creates the home directory (ADR 079), writes a default config if none exists (the same scaffold as config init), and establishes the keystore if none exists: encrypted with a confirmed passphrase by default, or --dev for an unencrypted testnet keystore. It is idempotent - existing files are left untouched - and prints the home path and the next step. This makes the workshop’s first line a single btcr2 init, after which key generate never hits the accidental-first-seal path because the passphrase was already established, with confirmation, up front.

Crucially, btcr2 init never destroys secret keys. Its --force re-scaffolds the (regenerable) config, but it does not overwrite an existing keystore: re-establishing a keystore, which discards its keys, is only ever the explicit keystore init --force, and even that warns on standard error when it would discard keys. Coupling a routine “reset my config” gesture to unrecoverable key loss is exactly the footgun this lifecycle work exists to remove.

Consequences

Rejected alternatives