import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; import { HDKey } from '@scure/bip32'; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import type { BitcoinNetwork } from './types'; import { AezeedError, DEFAULT_AEZEED_PASSPHRASE, decipherMnemonic, isAezeedPhrase } from './aezeed/cipher-seed'; import { BackendError, WalletLockedError } from './types'; // Seed custody for the wallet sidecar. // // THE THREAT MODEL, stated plainly, because it is the whole point of this file: // // Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS // keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a // secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable // here and this is written from scratch. // // The seed is protected by TWO independent secrets, and an attacker needs BOTH: // // 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that // KEK wraps the random per-wallet DEK that actually encrypts the mnemonic. // 2. VAULT_STORE_KEY from the environment, applied by queries/wallet.ts (../../databases/officer_db) // over the already-encrypted envelope before it touches Postgres. // // Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the // passphrase, and a full server compromise still cannot spend while the wallet is locked, because a // locked wallet holds no key material in memory at all. // // WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and // receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine — // unlocking is required only to SIGN. This is the single most important property here: the wallet spends // almost all of its life locked and still fully readable. // // WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to // pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks // the window but does not close it. That is why the unlock TTL is short and defaults tight. const scrypt = promisify(scryptCb) as ( password: string | Buffer, salt: Buffer, keylen: number, options: { N: number; r: number; p: number; maxmem: number }, ) => Promise; // N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful: // this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem // is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws. const SCRYPT_N = 1 << 17; const SCRYPT_R = 8; const SCRYPT_P = 1; const SCRYPT_MAXMEM = 256 * 1024 * 1024; const KEY_LEN = 32; /** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */ // Two seed formats, two envelope versions. // // v1 — BIP39 mnemonic. The master seed is PBKDF2(mnemonic, "mnemonic" + passphrase). // v2 — LND aezeed cipher seed (what Zeus's embedded node produces). Its 16-byte entropy IS the BIP32 // master seed, with no PBKDF2 step at all. // // The bump is not cosmetic. Both formats are 24 words from the same wordlist, so a build that predates // aezeed support would happily read a v2 envelope, run the words through BIP39 derivation, and produce a // completely different — empty — wallet without erroring. Refusing an unknown version is what makes a // platform rollback safe. const ENVELOPE_VERSION_BIP39 = 1; const ENVELOPE_VERSION_AEZEED = 2; export type SeedKind = 'bip39' | 'aezeed'; export type SeedEnvelope = { v: number; /** base64, 16 bytes — scrypt salt for the KEK. */ salt: string; /** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */ wrappedDek: string; /** base64(iv[12] | tag[16] | ciphertext) — the seed phrase, encrypted under the DEK. */ seed: string; /** * Whether a seed passphrase is part of this seed — the BIP39 "25th word", or the aezeed passphrase. * Affects derivation, not secrecy. */ hasBip39Passphrase: boolean; }; // ── AES-256-GCM primitives ─────────────────────────────────────────────────────────────────────── function gcmEncrypt(key: Buffer, plaintext: Buffer): string { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key, iv); const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64'); } function gcmDecrypt(key: Buffer, blob: string): Buffer { const buf = Buffer.from(blob, 'base64'); if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE'); const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12)); decipher.setAuthTag(buf.subarray(12, 28)); return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]); } /** Best-effort scrub of a buffer we own. See the caveat in the header comment. */ function zeroize(buf: Buffer | null): void { if (buf) buf.fill(0); } // ── envelope construction ──────────────────────────────────────────────────────────────────────── async function deriveKek(passphrase: string, salt: Buffer): Promise { return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM, }); } /** * Prove an aezeed actually opens before we seal it. Sealing an undecipherable seed would store something * permanently unusable behind the owner's passphrase and only surface at unlock — possibly months later, * with the original backup long since put away. */ async function assertAezeedOpens(words: string[], seedPassphrase: string | undefined): Promise { try { const seed = await decipherMnemonic(words, seedPassphrase || DEFAULT_AEZEED_PASSPHRASE); seed.entropy.fill(0); } catch (err) { if (err instanceof AezeedError && err.code === 'BAD_PASSPHRASE') { throw new BackendError( 'this is an LND (aezeed) seed and the passphrase is wrong — leave it blank if you never set one', 400, 'BAD_SEED_PASSPHRASE', ); } throw new BackendError('not a valid aezeed cipher seed', 400, 'BAD_MNEMONIC'); } } /** * Wrap a seed phrase into a sealed envelope. The phrase is validated first — importing a typo'd phrase * silently produces a valid-but-wrong wallet that shows a zero balance, which is a genuinely awful * failure mode to debug. * * The format is detected from the phrase itself rather than asked of the caller. A BIP39 mnemonic and an * LND aezeed are both 24 words drawn from the same wordlist and are indistinguishable by eye, so the * owner cannot reasonably be expected to know which one their wallet gave them — but the bits can tell * us for certain (SHA-256 checksum vs CRC32-Castagnoli). */ export async function sealSeed( mnemonic: string, ownerPassphrase: string, bip39Passphrase?: string, ): Promise { const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase(); const words = normalized.split(' '); let kind: SeedKind; if (validateMnemonic(normalized, wordlist)) { kind = 'bip39'; } else if (isAezeedPhrase(words)) { kind = 'aezeed'; await assertAezeedOpens(words, bip39Passphrase); } else { throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC'); } if (ownerPassphrase.length < 8) { throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE'); } const salt = randomBytes(16); const kek = await deriveKek(ownerPassphrase, salt); const dek = randomBytes(KEY_LEN); // The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the // words themselves, since together they are the wallet. const payload = Buffer.from( JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '', kind }), 'utf8', ); try { return { v: kind === 'aezeed' ? ENVELOPE_VERSION_AEZEED : ENVELOPE_VERSION_BIP39, salt: salt.toString('base64'), wrappedDek: gcmEncrypt(kek, dek), seed: gcmEncrypt(dek, payload), hasBip39Passphrase: Boolean(bip39Passphrase), }; } finally { zeroize(kek); zeroize(dek); zeroize(payload); } } /** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */ export function generateSeed(words: 12 | 24 = 24): string { return generateMnemonic(wordlist, words === 12 ? 128 : 256); } type OpenedSeed = { mnemonic: string; bip39Passphrase: string; kind?: SeedKind }; async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise { if (env.v !== ENVELOPE_VERSION_BIP39 && env.v !== ENVELOPE_VERSION_AEZEED) { throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE'); } const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64')); let dek: Buffer | null = null; try { // A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a // verifier hash of the passphrase, because a verifier is an offline-crackable oracle. dek = gcmDecrypt(kek, env.wrappedDek); const payload = gcmDecrypt(dek, env.seed); try { const opened = JSON.parse(payload.toString('utf8')) as OpenedSeed; // Envelopes sealed before aezeed support carry no `kind`; the version is the fallback authority, // and v1 has only ever meant BIP39. return { ...opened, kind: opened.kind ?? (env.v === ENVELOPE_VERSION_AEZEED ? 'aezeed' : 'bip39') }; } finally { zeroize(payload); } } catch (err) { if (err instanceof BackendError) throw err; throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE'); } finally { zeroize(kek); zeroize(dek); } } // ── derivation ─────────────────────────────────────────────────────────────────────────────────── export type Bip = 44 | 49 | 84 | 86; /** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */ export function coinType(network: BitcoinNetwork): 0 | 1 { return network === 'bitcoin' ? 0 : 1; } export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string { return `m/${bip}'/${coinType(network)}'/${account}'`; } /** * The one place the two seed formats diverge, and the reason `kind` has to be carried at all. * * BIP39 stretches the words into a 64-byte master seed via PBKDF2. aezeed does not stretch anything — * the 16 bytes recovered from the cipher seed ARE the BIP32 master seed, exactly as lnd/btcwallet uses * them. Feeding an aezeed phrase through the BIP39 path would succeed, derive real-looking keys, and * point at an entirely different (empty) wallet. */ async function rootFromSeed(opened: OpenedSeed): Promise { if (opened.kind === 'aezeed') { const seed = await decipherMnemonic( opened.mnemonic.split(' '), opened.bip39Passphrase || DEFAULT_AEZEED_PASSPHRASE, ); try { return HDKey.fromMasterSeed(seed.entropy); } finally { seed.entropy.fill(0); } } const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined)); try { return HDKey.fromMasterSeed(seed); } finally { zeroize(seed); } } /** * Derive the public account descriptors WITHOUT retaining any private material. Called once at import * time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work. */ export async function deriveAccountXpubs( env: SeedEnvelope, ownerPassphrase: string, network: BitcoinNetwork, ): Promise<{ fingerprint: string; xpubs: Record }> { const opened = await openEnvelope(env, ownerPassphrase); const root = await rootFromSeed(opened); try { const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer)) .reverse() .toString('hex'); const xpubs = {} as Record; for (const bip of [44, 49, 84, 86] as const) { const node = root.derive(accountPath(bip, network)); xpubs[bip] = node.publicExtendedKey; } return { fingerprint, xpubs }; } finally { root.wipePrivateData(); } } // ── the unlock session ─────────────────────────────────────────────────────────────────────────── const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900); // Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with // the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per // wallet id and resets on success. const MAX_ATTEMPTS = 5; const LOCKOUT_MS = 60_000; type Attempts = { count: number; lockedUntil: number }; const attempts = new Map(); function checkLockout(walletId: number): void { const a = attempts.get(walletId); if (a && a.lockedUntil > Date.now()) { const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000); throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT'); } } function recordFailure(walletId: number): void { const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 }; a.count += 1; if (a.count >= MAX_ATTEMPTS) { a.lockedUntil = Date.now() + LOCKOUT_MS; a.count = 0; } attempts.set(walletId, a); } /** * A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself * is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained. * * Structurally satisfies the `Signer` interface that backends/onchain.ts consumes. */ export class UnlockSession { private root: HDKey | null = null; private timer: ReturnType | null = null; private expiresAt = 0; constructor(readonly walletId: number) {} isUnlocked(): boolean { return this.root !== null && Date.now() < this.expiresAt; } /** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */ secondsRemaining(): number { if (!this.isUnlocked()) return 0; return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000)); } async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise { const opened = await openGuarded(this.walletId, env, ownerPassphrase); this.lock(); // replace any existing session rather than leaking the old root this.root = await rootFromSeed(opened); // Drop the words immediately — the root key is all any signing operation needs. opened.mnemonic = ''; opened.bip39Passphrase = ''; this.arm(ttlSec); } private arm(ttlSec: number): void { this.expiresAt = Date.now() + ttlSec * 1000; this.timer = setTimeout(() => this.lock(), ttlSec * 1000); // Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway. this.timer.unref?.(); } lock(): void { if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.root?.wipePrivateData(); this.root = null; this.expiresAt = 0; } /** * Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a * return value by construction — callers get a derived signature, not the key. * * Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose; * refreshing it on use would let a compromised session stay open indefinitely by signing. */ withRoot(fn: (root: HDKey) => T): T { if (!this.isUnlocked() || !this.root) { this.lock(); throw new WalletLockedError(); } return fn(this.root); } } // One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock // state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks // every wallet, which is the correct default. const sessions = new Map(); export function sessionFor(walletId: number): UnlockSession { let s = sessions.get(walletId); if (!s) { s = new UnlockSession(walletId); sessions.set(walletId, s); } return s; } export function lockAll(): void { for (const s of sessions.values()) s.lock(); } /** * Open an envelope under the same backoff `unlock()` uses. * * EVERY passphrase check goes through here, not just the session one. The lockout used to live inside * UnlockSession.unlock alone, which left export-seed — the one endpoint that returns the words in the * clear — accepting unlimited guesses, while /unlock capped at five a minute. An attacker with a session * cookie would simply never have used /unlock. */ async function openGuarded(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { checkLockout(walletId); let opened: OpenedSeed; try { opened = await openEnvelope(env, ownerPassphrase); } catch (err) { recordFailure(walletId); throw err; } attempts.delete(walletId); return opened; } /** * Verify a passphrase without opening a session — used before destructive operations (seed export, * wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked. * * Rethrows a LOCKED_OUT BackendError rather than folding it into `false`: "wrong passphrase" and "stop * guessing" are different answers, and a caller that showed the first for both would loop forever. */ export async function verifyPassphrase(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { try { await openGuarded(walletId, env, ownerPassphrase); return true; } catch (err) { if (err instanceof BackendError && err.code === 'LOCKED_OUT') throw err; return false; } } /** * Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner * can back up or migrate. Always requires the passphrase even if a session is open, and callers must * gate it behind a fresh confirmation. */ export async function exportMnemonic(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise { const opened = await openGuarded(walletId, env, ownerPassphrase); return opened.mnemonic; } /** * 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( walletId: number, env: SeedEnvelope, oldPassphrase: string, newPassphrase: string, ): Promise { const opened = await openGuarded(walletId, env, oldPassphrase); return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined); } /** Constant-time compare for any confirmation token we hand out and take back. */ export function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); }