Files
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

485 lines
20 KiB
TypeScript

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. the 'wallet' key from the secret store, applied by queries/wallet.ts (officerdb)
// 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<Buffer>;
// 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<Buffer> {
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<void> {
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<SeedEnvelope> {
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<OpenedSeed> {
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<HDKey> {
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<Bip, string> }> {
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<Bip, string>;
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<number, Attempts>();
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<typeof setTimeout> | 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<void> {
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<T>(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<number, UnlockSession>();
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<OpenedSeed> {
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<boolean> {
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<string> {
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<SeedEnvelope> {
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);
}