diff --git a/docs/wallet-key-custody.md b/docs/wallet-key-custody.md new file mode 100644 index 00000000..6cef953f --- /dev/null +++ b/docs/wallet-key-custody.md @@ -0,0 +1,172 @@ +# 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/queries/wallet.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. diff --git a/src/servers/sidecar/wallet/keys.ts b/src/servers/sidecar/wallet/keys.ts index ef6856a2..69a04d1f 100644 --- a/src/servers/sidecar/wallet/keys.ts +++ b/src/servers/sidecar/wallet/keys.ts @@ -366,7 +366,11 @@ export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string) return opened.mnemonic; } -/** Re-wrap an existing seed under a new passphrase. Requires the old one; never touches the DEK. */ +/** + * Re-seal an existing seed under a new passphrase. Requires the old one. Note this mints a FRESH salt, + * DEK and IVs rather than merely re-wrapping the existing DEK — so a copy of the old envelope, plus the + * old passphrase, cannot decrypt anything written after a rotation. + */ export async function changePassphrase( env: SeedEnvelope, oldPassphrase: string,