did-btcr2-js

ADR 081: A Session Unlock Agent for the Encrypted Keystore

Status: Accepted

Date: 2026-07-14

Branch / PR: feat/cli-keystore-unlock-agent

References: ADR 052, ADR 077, ADR 079, ADR 080

Context

The encrypted keystore (ADR 080) seals each secret in its own argon2id + XChaCha20-Poly1305 envelope under one shared passphrase, acquired lazily by getPassphrase (config.ts) whenever a secret is sealed or opened. Every btcr2 invocation is a fresh process, so nothing carries a decrypted secret (or the passphrase) from one command to the next: a returning operator is prompted again on the very next key generate, update, or deactivate. ADR 080 deliberately deferred the convenience of not re-prompting to its own decision, which is this one.

The workshop happy path is init then a run of key and DID commands. Retyping the passphrase between each, or exporting BTCR2_KEYSTORE_PASSPHRASE (which leaks it into shell history and every child process for the whole shell lifetime), is exactly the friction that pushed four test keys onto the accidental-first-seal path in the first place. A returning operator wants to authenticate once and then run a short series of commands unattended, the way ssh-agent lets one ssh-add cover a working session.

Two facts constrain the mechanism:

  1. There is no single “unlock key” to cache. Each secret carries its own random argon2id salt, so opening any secret requires the passphrase to re-derive that secret’s key (or the already-decrypted bytes). A cached argon2id output opens exactly one envelope, not the store. And sealing a new key (the most common workshop operation) needs the passphrase string to run argon2id over a fresh salt. The only thing that covers both open and seal across a fresh process is the passphrase itself.
  2. The audience is cross-OS and types commands literally. An eval $(btcr2 keystore unlock) handshake (secret in the shell environment, ciphertext on disk) is the strongest on-disk-adjacent design, but it is not portable to Windows PowerShell and it silently does nothing if an attendee forgets the eval. The next btcr2 process must therefore pick up the unlocked session on its own, by reading a file, with no shell integration.

Decision

Add a session unlock agent: keystore unlock caches the verified passphrase in a single file under the home directory, and subsequent commands consume it in place of a prompt until it expires or is revoked. This is an explicit v1, on-disk design; a future in-memory/socket agent (v2) is the real fix for the residual it carries, and is out of scope here.

What is cached, and where

A session file at <home>/session.json (colocated with config.json and keystore.json per ADR 079), mode 0600, written atomically (temp sibling + rename). It holds:

The file never holds a derived key, any keystore ciphertext, or any signing-key bytes. The passphrase is structurally absent from every command result, from stdout/stderr, and from verbose logs.

We cache the passphrase rather than a random session key wrapping the secrets. With per-secret salts, a random wrap key would have to sit in the same 0600 file as the ciphertext it opens, so it protects nothing a passphrase cache does not against a reader of that file, while forcing edits into the audited FileKeyStore secret path and leaving new-key sealing still prompting. Caching the passphrase changes only the getPassphrase seam and leaves the audited store untouched. Its one genuine disadvantage, that the reusable human passphrase (not just this keystore’s keys) is what leaks if the file is read, is precisely what the deferred in-memory v2 removes.

How a session is consumed

acquirePassphrase gains an optional beforePrompt source, consulted after the environment variable and --passphrase-file and before the “no TTY” failure, so a non-interactive follow-on command (piped output, a task runner) consumes a live session instead of hard-failing. buildKeystoreKms wires beforePrompt to read the session, but only when it is not establishing a passphrase: establishment always prompts twice, fresh, so ADR 080’s confirmed-first-passphrase guarantee is untouched. The precedence is therefore: BTCR2_KEYSTORE_PASSPHRASE, then --passphrase-file, then a live session, then an interactive prompt. Unattended and CI paths keep winning over the cache and are never weakened by it.

A cached passphrase is still checked by the store’s existing verifier on every use (#assertPassphrase), so a forged or stale cache can never seal a key under a divergent passphrase; the ADR 080 key-loss class stays closed by construction.

The commands

Mainnet is gated at unlock and enforced at consumption

An unlocked encrypted keystore signs prompt-free for the whole TTL, which silently removes per-use passphrase authentication, including for a bitcoin DID (ADR 080’s dev-keystore refusal does not cover an encrypted mainnet key). Keys are not network-tagged, and the network a command signs under is derived per-operation (from the DID for update/deactivate, from --network/config for create), not from the configured default. So the gate is keyed to --allow-mainnet in two places:

The workshop is on mutinynet, so the gate costs attendees nothing; a keystore genuinely reused for mainnet is the honest edge the explicit flag exists for. Testnet, signet, mutinynet, and regtest unlock and sign without a flag.

Hardening the on-disk read

Because the file holds a plaintext passphrase, the read path is defensive and never throws (a bad session degrades to a prompt, never a crash):

Consequences

Rejected alternatives