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>
389 lines
15 KiB
TypeScript
389 lines
15 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 { 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<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. */
|
|
const ENVELOPE_VERSION = 1;
|
|
|
|
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 BIP39 mnemonic, encrypted under the DEK. */
|
|
seed: string;
|
|
/** Whether a BIP39 passphrase (the "25th word") is part of this seed. 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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wrap a mnemonic into a sealed envelope. The mnemonic is validated against the BIP39 wordlist 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.
|
|
*/
|
|
export async function sealSeed(
|
|
mnemonic: string,
|
|
ownerPassphrase: string,
|
|
bip39Passphrase?: string,
|
|
): Promise<SeedEnvelope> {
|
|
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
if (!validateMnemonic(normalized, wordlist)) {
|
|
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 ?? '' }), 'utf8');
|
|
|
|
try {
|
|
return {
|
|
v: ENVELOPE_VERSION,
|
|
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 };
|
|
|
|
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
|
|
if (env.v !== ENVELOPE_VERSION) {
|
|
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 {
|
|
return JSON.parse(payload.toString('utf8')) as OpenedSeed;
|
|
} 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}'`;
|
|
}
|
|
|
|
function rootFromSeed(opened: OpenedSeed): HDKey {
|
|
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 = 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> {
|
|
checkLockout(this.walletId);
|
|
let opened: OpenedSeed;
|
|
try {
|
|
opened = await openEnvelope(env, ownerPassphrase);
|
|
} catch (err) {
|
|
recordFailure(this.walletId);
|
|
throw err;
|
|
}
|
|
attempts.delete(this.walletId);
|
|
|
|
this.lock(); // replace any existing session rather than leaking the old root
|
|
this.root = 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();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
|
|
try {
|
|
await openEnvelope(env, ownerPassphrase);
|
|
return true;
|
|
} catch {
|
|
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(env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
|
|
const opened = await openEnvelope(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(
|
|
env: SeedEnvelope,
|
|
oldPassphrase: string,
|
|
newPassphrase: string,
|
|
): Promise<SeedEnvelope> {
|
|
const opened = await openEnvelope(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);
|
|
}
|