Files
platform/docs/wallet-key-custody.md
T
pastilhasandClaude Opus 5 b442084618 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>
2026-07-31 07:09:03 +00:00

10 KiB

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.tsseedPhrase?: 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.