# Wallet key custody How the `officer-wallet` sidecar stores BIP39 seed phrases and node credentials, and what each layer does and does not protect against. Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing, derivation, unlock sessions), `src/databases/officer_db/src/crypto.ts` (storage encryption) and `src/databases/officer_db/src/wallet/queries.ts` (where the two meet). ## The requirement The owner's seed phrase must never be stored in a browser or a phone app. Clients call the server; the server holds the keys. That inverts the usual mobile-wallet model and it is the reason none of Zeus's key handling was reused. **Zeus stores seed phrases as plaintext in a JSON settings blob** (`storage/index.ts`, `stores/SettingsStore.ts` — `seedPhrase?: string[]`) and relies entirely on the OS keychain. That is defensible on a phone, which has a secure enclave and a screen lock. A server has neither, so the custody layer here is written from scratch. ## Two independent secrets An attacker needs **both** to spend. Neither alone is sufficient. ``` mnemonic + BIP39 passphrase │ │ AES-256-GCM under DEK ▼ seed ──────────────┐ │ DEK (32 random bytes) │ │ │ │ AES-256-GCM under KEK ▼ │ wrappedDek ───────────┤ │ KEK = scrypt(owner passphrase, salt, N=2^17) salt (16 random bytes)┤ ▼ SeedEnvelope {v, salt, wrappedDek, seed, hasBip39Passphrase} │ │ AES-256-GCM under SHA-256(VAULT_STORE_KEY) ▼ wallet_wallets.seed_envelope (Postgres) ``` ### Layer 1 — envelope encryption (the sidecar) Classic envelope encryption, in `keys.ts`: - The mnemonic **and** the BIP39 passphrase (the "25th word") are serialized into one JSON payload and encrypted with a random per-wallet **DEK**. They travel together because together they _are_ the wallet — storing the 25th word beside the ciphertext would defeat its purpose. - The DEK is wrapped under a **KEK** derived from the owner's passphrase by **scrypt, N=2¹⁷ / r=8 / p=1**, which costs roughly 128 MiB and ~1 s per attempt on this class of hardware. That cost is the entire point: it is what stands between a full disclosure of the database _and_ `.env` and the coins. Node's default `maxmem` (32 MiB) is too low for these parameters, so it is raised explicitly to 256 MiB or scrypt throws. - Fresh 16-byte salt and fresh 12-byte IVs on every seal. Two wallets holding the same seed are not byte-identical at rest, so a database dump does not reveal which wallets share a seed. - `ENVELOPE_VERSION` is stamped into the envelope so KDF parameters can be migrated on a future unlock. ### Layer 2 — storage encryption (the database) `queries/wallet.ts` runs the already-sealed envelope through `encryptSecret()` before it reaches Postgres — AES-256-GCM under `SHA-256(VAULT_STORE_KEY)`. This is the platform's existing vault primitive, shared with `headscale_servers.api_key` and others; the wallet does not introduce its own. Callers of the query layer deal in "plaintext", which for `seedEnvelope` means _the sealed envelope_ — still opaque without the passphrase. ## What each layer actually buys you | Attacker has | Result | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Postgres dump only | Useless. Layer 2 is unopened; there is nothing to attack. | | `.env` only | Useless. No ciphertext. | | Dump **and** `.env` | Layer 2 falls. Layer 1 remains: an offline scrypt grind at ~1 s and 128 MiB per guess against the passphrase. | | Live root on the server, wallet locked | Cannot spend. A locked wallet holds no key material in memory at all. | | Live root, wallet unlocked | Can spend, for the remainder of the TTL. This is the window the design minimises rather than eliminates. | **Layer 2 buys nothing in the third row.** Its job is the dump-only case. Layer 1's scrypt is what has to hold when both secrets are gone — size the passphrase accordingly. ## Watch-only while locked `fingerprint` and `xpubs` are stored **in the clear, deliberately**. That is what lets balances, transaction history and fresh receive addresses work with the wallet locked and the passphrase nowhere on the machine. Unlocking is required only to **sign**. This is the most important ergonomic property here: the wallet spends almost all of its life locked and still fully readable, so there is no incentive to leave it unlocked "for convenience". The cost is privacy, not funds. An xpub leak lets an observer enumerate the wallet's addresses and link its history. It can never move a coin. ## Unlock sessions `UnlockSession` (one per wallet id, held in a process-wide map in the sidecar): - Holds **only the derived HD root**. The mnemonic is decrypted, converted to a root key, and dropped inside `unlock()` — never retained. - **TTL defaults to 900 s** (`WALLET_UNLOCK_TTL_SEC`). A caller may request a _shorter_ window but never one longer than the deployment maximum; `unlockRoute` clamps with `Math.min`. - **The TTL does not slide on use.** An unlock is a bounded window the owner opened on purpose; refreshing it on every signature would let a hijacked session stay open indefinitely by signing. - `withRoot(fn)` is the only path by which key material leaves the class, and by construction the key cannot escape as a return value — callers get a signature, not a key. - **Brute-force backoff**: 5 failed attempts per wallet id → 60 s lockout, reset on success. scrypt already makes each guess expensive; this protects the live endpoint specifically, since an attacker holding the DB and `.env` can grind offline regardless. - **No persistence, by design.** A sidecar restart relocks every wallet. ### Wrong passphrases are detected by GCM tag mismatch alone There is **no stored verifier hash**. A verifier would be an offline-crackable oracle sitting right next to the ciphertext, so the only signal that a passphrase is wrong is that the AES-GCM authentication tag fails to validate. `openEnvelope` translates that into a `401 BAD_PASSPHRASE`. ## Operations that require the passphrase Each of these re-checks the passphrase **even when a session is already open** — an open session is never sufficient authority for a destructive or disclosing action. | Operation | Route | Gate | | ---------------------- | -------------------- | ------------------------------------------------------------- | | Unlock | `POST …/unlock` | passphrase; TTL clamped to the configured max | | Rotate passphrase | `POST …/passphrase` | old passphrase; forces a re-unlock afterwards | | Export seed | `POST …/export-seed` | passphrase; logs a `console.warn` audit line | | Delete a seeded wallet | `DELETE …` | passphrase, because this destroys the only copy Officer holds | Rotation (`changePassphrase`) re-seals with a **fresh salt, DEK and IVs** rather than re-wrapping the existing DEK. A copy of the old envelope plus the old passphrase therefore cannot decrypt anything written after a rotation. ## Node credentials are protected differently, on purpose `wallet_wallets.config` holds macaroons, runes, LNDHub passwords and NWC URIs. It is encrypted with `VAULT_STORE_KEY` only — **layer 2 without layer 1**. This asymmetry is deliberate and is documented in the schema. Background balance polling needs those credentials without the owner present, so they cannot sit behind a passphrase. The consequence is that a compromise of the database _and_ `.env` exposes node credentials in a way it does not expose the seed. Treat a connected node as a hot wallet; treat the seed as cold. ## Known limits 1. **Memory cannot be reliably wiped.** Once unlocked, the root key is in the Bun process heap and Node offers no way to pin or scrub it — the GC may already have copied it. `zeroize()` scrubs the buffers we own, which narrows the window but does not close it. The short default TTL is the mitigation. 2. **Layer 2 derives its key with a plain `SHA-256`, not a KDF.** This is correct for a high-entropy random `VAULT_STORE_KEY` and weak for a human-memorable one — with a database dump in hand, a low-entropy value is cheaply brute-forceable. **Set `VAULT_STORE_KEY` to a long random string.** This is pre-existing shared platform crypto, not wallet-specific. 3. **A `VAULT_STORE_KEY` rotation has no migration path yet.** Changing it orphans every stored envelope and config. Back up seeds before touching it. 4. **The unlock map is per-process.** Correct today, because the sidecar is a single process. If `officer-wallet` is ever run clustered, unlock state will need rethinking rather than sharing. ## Verifying the claims The properties above are asserted in `src/servers/sidecar/wallet/keys.test.ts` (13 tests): the envelope never contains the plaintext mnemonic, sealing is non-deterministic while derivation stays stable, checksum and weak-passphrase rejection, rotation preserves the derived xpubs, and a failed unlock leaves the session locked. At the storage layer the claim was checked directly against a live import — `seed_envelope` was 553 opaque bytes with zero occurrences of any mnemonic word and no readable envelope JSON keys, confirming that layer 2 is genuinely applied on top of layer 1 rather than either one alone.