document the wallet key custody model
covers both encryption layers, what each one does and does not protect against, the watch-only-while-locked property and the unlock session rules. records the known limits: the heap cannot be reliably wiped, the storage key is derived with a plain sha-256 rather than a kdf, and rotating VAULT_STORE_KEY has no migration path. also corrects the changePassphrase doc comment, which claimed rotation never touches the dek. it mints a fresh salt, dek and ivs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user