Files
platform/docs/wallet-key-custody.md
T
pastilhasandClaude Opus 5 68f2c55ecf one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.

The parallel trees had drifted, which is what the restructure is really fixing:

  four features were named differently on each side — app-store/sidecar-installs,
  email/email-accounts, server/server-config

  operations had a schema and NO query file: its task_logs is reached directly
  from src/servers/api/task-logger.ts, bypassing this package's own boundary

  integrations had queries and NO schema, because it spans two features'
  tables — server_integrations and user_integrations

Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.

Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.

schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.

Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.

One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.

Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:34:04 +00:00

173 lines
10 KiB
Markdown

# 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.