# Local Vault: file format, recovery, and backup Local vault is `devboy-tools`'s own encrypted local secret store. One file, three unlock paths, a recovery phrase for emergencies. This document explains the format, when to pick local-vault over the OS keychain or external sources, and how to back it up safely. See [ADR-023](https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md) §3.1–§3.3 and [ADR-021](https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-secret-source-router.md) §4 for the full specification. > Russian translation: [`ru/local-vault.md`](./ru/local-vault.md). --- ## When to use local-vault | Scenario | Recommended source | Why | |---|---|---| | One developer, one machine, macOS / Win / Linux | **OS keychain** | Already there, nothing to install, biometric unlock out of the box. | | A team with shared tokens (CI, deploy) | **1Password / Vault** | Centralised access, audit, rotation. | | Server-side CI without an interactive login | **env-store** (env vars) or **vault** (HTTP KV v2) | No UI, no interactivity. | | Local playground without keychain access (containers, devcontainers, bare Linux without gnome-keyring) | **local-vault** | One file — easy to mount into a container, no system-service dependency. | | Developer juggling several identities (work / personal / sandbox) | **local-vault** + several files | One vault per context, switch without rewriting keychain entries. | | Auto-agent in a headless environment where the OS keychain wants the screen unlocked | **local-vault** + passphrase from a safe env-var | No UI prompt, scriptable unlock. | > **Main criterion**: local-vault is for cases where there is *no* good system store. If the OS keychain or 1Password is available, prefer them. Local-vault gives you portability and file-level control at the cost of owning backups and key rotation yourself. ## Unlocking from the secrets UI `devboy secrets ui` resolves the backend three ways at startup, in order: 1. **`DEVBOY_VAULT_PASSPHRASE` is set** → the vault opens straight away, no prompt. Scriptable; right for headless / auto-agent runs. 2. **A `.dvb` file exists** (default `/devboy-tools/secrets/local-vault.dvb`, override with `DEVBOY_VAULT_PATH`) **but no env passphrase** → the UI opens a modal **unlock prompt** over a dimmed inventory. Type the passphrase, hit *Unlock*. A wrong passphrase shows in red and the modal stays open. *Use keychain instead* is the escape hatch — it switches to the OS keychain for that session. 3. **No `.dvb` file, no env passphrase** → the backend defaults to the OS keychain. A **Switch to encrypted vault** button in the top bar opens a **create-vault** modal: pick a passphrase, confirm it, and the UI mints the vault and shows the recovery phrase once — behind an explicit *I've saved this phrase* gate. Once unlocked, the top bar offers **Lock vault** (drops the passphrase, the unlock prompt returns) and **Switch to keychain**. The switch is session-scoped — the env var stays the durable, machine-level override. The passphrase typed into the modal lives in a `SecretString` and is zeroized when the modal closes; the agent never sees it (same contract as the provision dialog). ## First-run onboarding wizard On the very first launch — no `sources.toml`, no `.dvb` file, no `DEVBOY_VAULT_PASSPHRASE` — `devboy secrets ui` shows an **onboarding wizard** instead of jumping to the keychain. It's a backend picker: - **OS keychain** — pre-ticked, zero config. - **Encrypted local vault** — ticking it reveals passphrase + confirm fields; on finish the `.dvb` file is created and its recovery phrase is shown once. - **HashiCorp Cloud Vault** — ticking it reveals address / namespace / mount / token fields plus a **read-only / read-write** access radio. Combinations are allowed — all three can be configured at once. One is the **primary** backend (a radio over the ticked providers) — the one the UI works against today. *Finish setup* writes a `sources.toml` with one `[[source]]` per ticked backend and `[default].source` pointing at the primary; *Skip — use keychain* goes straight to the keychain, writing nothing. The wizard never re-appears once `sources.toml` exists. ## External Vault as a read-source (HCP / HashiCorp Cloud) A `type = "vault"` `[[source]]` points devboy at a remote HashiCorp Vault over the HTTP KV v2 API. HCP Vault (the managed offering) needs a `namespace` (conventionally `admin`); plain open-source Vault leaves it blank. Settings: ```toml [[source]] name = "hcp-vault" type = "vault" access = "read" # or "readwrite" addr = "https://.hashicorp.cloud:8200" namespace = "admin" # HCP / Enterprise; omit for OSS Vault mount = "secret/data" # KV v2 data-mount prefix token = "hvs.…" # Vault token ``` An ADR-020 path `team/openai/api-key` is read from the Vault reference `/team/openai/api-key`. > **Read-only today.** The `SecretSource` trait has no write surface yet — that ships in **P15**. So even `access = "readwrite"` cannot *write* to Vault from the UI yet; the UI refuses a save with an honest message and you write the value with the Vault CLI / UI, then devboy reads it back. `access = "read"` is the safe, working choice; `access = "readwrite"` is forward-looking config. ### The `access` mask `access = "read"` narrows whatever the source plugin declares to `READ | LIST | VALIDATE` — `WRITE` / `ROTATE` are masked off even when the backend supports them. Use it to mount a team's shared Vault read-only on most machines and leave `readwrite` only where rotation runs. `doctor`'s "Sources" card shows both the *declared* and the *effective* (masked) capability set, so a refused write is explainable. The mask can only narrow — it never grants a capability the plugin lacks. > **Multi-source routing is deferred (P11).** The onboarding wizard writes every selected backend into `sources.toml`, but the UI drives exactly *one* — the primary — until the P11 router orchestration lands. The other entries are recorded and ready. ## File format The file lives at `~/.devboy/secrets/local-vault.dvb` by default. Binary layout (see `crates/devboy-vault-crypto/src/format.rs`): ```text HEADER (53 bytes, fixed) MAGIC [4] = b"DVB1" // sanity check + format version VERSION [1] = 0x01 KDF_PARAMS [16] = m_cost u32 LE, // Argon2id memory cost (KiB) t_cost u32 LE, // iterations p_cost u32 LE, // parallelism salt_len u32 LE SALT [32] = random 32 bytes (KDF salt for the passphrase envelope) UNLOCK_ENVELOPES (length-prefixed TOML) [envelopes_len: u32 LE][envelopes_bytes...] // One envelope per unlock method. Each envelope independently // stores an encrypted copy of the vault-key. ENTRIES_INDEX (length-prefixed TOML, metadata) [entries_len: u32 LE][entries_bytes...] // path, description, retrieval_url, expires_at, pattern_id, … // No values in the index — it is read without unlock. AEAD_BLOBS (length-prefixed binary stream) [blobs_len: u64 LE][blob_bytes...] // Encrypted values, one per entry. AEAD: XChaCha20-Poly1305. // AAD is bound to the envelope kind so a blob cannot be moved // between incompatible keys. ``` The `DVB1` magic is enough for `file local-vault.dvb` to tell a vault from random bytes. ### Metadata is readable without unlock `description`, `retrieval_url`, `expires_at`, `rotation_method`, and `pattern_id` are stored in the clear. This is intentional — `secrets list`, `doctor`, and discovery flows must work without a PIN prompt. Threat model: a local process with read access to the file already sees what `ls -la` exposes. Encrypting the metadata would add a prompt with no real protection. ## Unlock paths (envelopes) One vault can support several unlock methods at once. Each method is a separate envelope that independently stores an encrypted copy of the `vault-key`. Any envelope unlocks the same blobs. ### 1. Passphrase envelope (default) ```text unlock-key = HKDF(Argon2id(passphrase, salt, KDF_PARAMS), info="devboy-vault-envelope:passphrase:v1") wrapped_key = XChaCha20-Poly1305(unlock-key).encrypt(vault-key, aad="devboy-vault-envelope:passphrase:v1") ``` Default Argon2id parameters (`KdfParams::DEFAULT`): m=64 MiB, t=3, p=1, salt 32 B. On 2024-class hardware one unlock takes ~250 ms. Change parameters via `devboy secrets vault rekey` — the old salt is preserved so other envelopes keep working. ### 2. Keychain envelope (optional) The OS keychain stores a fresh random 32-byte key; the envelope's `wrapped_key` is `XChaCha20-Poly1305(keychain_key).encrypt(vault-key)`. Convenient: the vault unlocks via a single fingerprint / PIN prompt from the OS, no passphrase typing. Available on macOS / Windows / Linux with gnome-keyring; other platforms — passphrase + recovery phrase only. ### 3. Recovery phrase envelope (BIP39) 12-word BIP39 mnemonic, generated when the vault is created. The envelope uses `HKDF(BIP39_seed)` without Argon2id (the phrase is high-entropy on its own). The recovery phrase is the spare key for cases where: - The passphrase is forgotten. - The keychain entry is wiped (new OS, reinstall). - The vault file moves to a machine without a keychain. The recovery phrase is **never** stored automatically. The CLI prints it once when the vault is created; you decide where to put it (paper list, password manager, sealed envelope). > **⚠ Without the recovery phrase: potentially permanent loss**: if the passphrase is forgotten *and* the keychain is unavailable *and* the recovery phrase was never saved — the vault cannot be unlocked. There is no key escrow in the system; brute-forcing Argon2id with default parameters takes years. Back up the recovery phrase separately from the vault file. ## Recovery: what to do when something breaks ### Scenario A — passphrase forgotten 1. If a keychain envelope exists → unlock via keychain: ```bash devboy secrets agent start --use-keychain devboy secrets vault rekey --new-passphrase ``` `rekey` recreates the passphrase envelope with a new phrase; the keychain envelope stays valid. 2. If the keychain is also gone → recovery phrase: ```bash devboy secrets vault unlock --recovery # Type the 12 BIP39 words devboy secrets vault rekey --new-passphrase ``` 3. If neither keychain nor recovery phrase is available → restore from a vault-file backup taken *before* the key was lost. ### Scenario B — vault file is corrupt The magic is wrong / the TOML doesn't parse / a blob fails to decrypt: 1. Don't panic — do not write anything over the corrupt file. 2. Copy `~/.devboy/secrets/local-vault.dvb` somewhere safe (`local-vault.dvb.broken`). 3. Restore from the latest backup. 4. Run `devboy secrets validate`. Green → continue. 5. Investigate what corrupted the file: full disk, `kill -9` mid-write, manual editing. Atomic writes (`tmpfile + rename`) rule out interrupt-based corruption but not external tampering. ### Scenario C — migrate the vault to a new machine The vault is one file, so migration is trivial: ```bash # On the old machine cp ~/.devboy/secrets/local-vault.dvb /tmp/vault-export.dvb # Move over a secure channel (not email, not Slack) scp /tmp/vault-export.dvb new-host:/tmp/ # On the new machine mkdir -p ~/.devboy/secrets mv /tmp/vault-export.dvb ~/.devboy/secrets/local-vault.dvb chmod 600 ~/.devboy/secrets/local-vault.dvb devboy secrets vault unlock --passphrase # if you know the passphrase # or devboy secrets vault unlock --recovery # recovery only ``` After unlocking on the new machine, add a fresh keychain envelope: ```bash devboy secrets vault add-envelope --kind keychain ``` ## Backups The vault is one file, so backup = regular copy + recovery phrase stored separately. ### Minimum policy (one developer) 1. Once a day, cron / `systemd --user` copies `~/.devboy/secrets/local-vault.dvb` to encrypted storage (Time Machine, Backblaze, a Restic repo with its own password). 2. Recovery phrase on paper in a physically secure place *and* in a personal password manager (e.g. 1Password Personal). Never with the vault file. 3. Once a quarter — restore drill: deploy the backup to a clean machine/container and try unlocking through the recovery phrase. ### What **not** to do - ❌ Put the vault and the recovery phrase in the same backup directory. If the backup leaks, everything leaks. - ❌ Store the recovery phrase as a plaintext file in a repo / on a Git server. BIP39 is high-entropy, but a public leak turns it into a one-step unlock key. - ❌ Send the vault file over email / messengers. AEAD protects values, but the KDF salt in the header leaks, which speeds up offline passphrase brute-force. Use a secure channel (`scp`, `magic-wormhole`, S3 with KMS). - ❌ Use a weak passphrase. Argon2id slows brute-force, but `password123` still falls. Minimum: 4 random `diceware`/`xkpasswd` words or 16+ characters from a password manager. ### A good workflow ```bash # Backup (Restic for transparent dedup) restic -r s3:s3.amazonaws.com// backup ~/.devboy/secrets/local-vault.dvb # Quarterly drill restic -r s3:... restore latest --target /tmp/restore DEVBOY_VAULT_PATH=/tmp/restore/local-vault.dvb devboy secrets vault unlock --recovery DEVBOY_VAULT_PATH=/tmp/restore/local-vault.dvb devboy secrets list rm -rf /tmp/restore ``` ## Security boundary - **AEAD choice**: XChaCha20-Poly1305. The long nonce (24 bytes) lets us pick it randomly without a counter. Throughput is on par with ChaCha20. - **AAD**: every ciphertext is bound to its envelope kind (`passphrase` / `keychain` / `recovery`). Moving a blob between incompatible envelopes is detected at decrypt time. - **Zeroize**: `vault-key` and `unlock-key` are wrapped in `secrecy::SecretBox`, which calls `zeroize` on drop. Hand-off through FFI to keychain backends uses minimal lifetimes. - **Lock posture**: after `idle_timeout` (default 60 s with no requests) the daemon zeroizes the in-memory keys. Unlock is required again. - **Does NOT protect against**: - A root/admin local process that can read the vault file *and* scrape envelope unlock-keys from the daemon's memory. - CPU side-channel attacks (Spectre-class). - Social engineering (phishing the recovery phrase). ## See also - [`onboarding.md`](./onboarding.md) — first-run install + source setup. - [`token-catalog.md`](./token-catalog.md) — author per-provider procedure files (`kimi.json`, `openai.json`) the GUI binds to. - [`catalog-url-sources.md`](./catalog-url-sources.md) — serve catalogs over the network with sha-pinning + audit log. - [`agent-protocol.md`](./agent-protocol.md) — how AI agents talk to the vault through MCP. - ADR-023 §3.1 (format) and §3.2–§3.3 (envelopes) — the formal spec. - `crates/devboy-vault-crypto/` — source for the format, AEAD, and KDF.