Files
bitcoin-wallet/sidecar/aezeed/cipher-seed.ts
T
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

203 lines
8.3 KiB
TypeScript

import { scrypt as scryptCb } from 'node:crypto';
import { promisify } from 'node:util';
import { wordlist } from '@scure/bip39/wordlists/english';
import { aezDecrypt } from './aez';
// LND's aezeed cipher seed — the format Zeus's embedded LND node hands out as a "backup phrase".
//
// It is NOT BIP39, and that is the entire problem this module exists to solve. An aezeed phrase is 24
// words drawn from the very same BIP39 English wordlist, so it is visually indistinguishable from a
// BIP39 mnemonic, but the bits underneath are a different structure entirely:
//
// byte 0 version (0)
// bytes 1..24 ciphertext (19-byte plaintext + 4-byte AEZ authenticator)
// bytes 24..29 salt (scrypt salt for the passphrase)
// bytes 29..33 CRC32-Castagnoli over bytes 0..29
// ─────
// 33 bytes = 24 words x 11 bits
//
// A BIP39 24-word phrase is also 33 bytes, but its last 8 bits are a SHA-256 checksum. So an aezeed
// phrase fails BIP39 validation every single time, with an error that reads exactly like "you mistyped
// a word" — which is unfixable advice, because nothing is mistyped.
//
// The deciphered plaintext is:
//
// byte 0 internal version
// bytes 1..3 birthday (days since the Bitcoin genesis block, big-endian uint16)
// bytes 3..19 entropy (16 bytes)
//
// That 16-byte entropy is used DIRECTLY as the BIP32 master seed — LND does not run it through BIP39's
// PBKDF2. This is why an aezeed cannot be converted into an equivalent BIP39 phrase: doing so would
// change the derived keys and produce an empty wallet at different addresses.
const scrypt = promisify(scryptCb) as (
password: string | Buffer,
salt: Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
export const CIPHER_SEED_VERSION = 0;
export const ENCIPHERED_SIZE = 33;
export const ENTROPY_SIZE = 16;
const PLAINTEXT_SIZE = 19;
const SALT_SIZE = 5;
const CHECKSUM_SIZE = 4;
const CIPHERTEXT_EXPANSION = 4;
const SALT_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE - SALT_SIZE; // 24
const CHECKSUM_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE; // 29
/**
* LND's default when the user declines to set a seed passphrase. Not a secret — it is a literal
* constant in lnd/aezeed, and every Zeus wallet created without an explicit passphrase uses it.
*/
export const DEFAULT_AEZEED_PASSPHRASE = 'aezeed';
// scrypt parameters for version 0, straight from lnd/aezeed/params.go.
const SCRYPT_N = 32768;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LEN = 32;
// 128 * N * r is ~33.5 MB here, over Node's 32 MB default, which fails with a bare MEMORY_LIMIT_EXCEEDED.
const SCRYPT_MAXMEM = 64 * 1024 * 1024;
/** LND counts a seed's birthday in days from this instant. */
const BITCOIN_GENESIS = Date.UTC(2009, 0, 3, 18, 15, 5);
const ONE_DAY_MS = 86_400_000;
// ── CRC32-Castagnoli ─────────────────────────────────────────────────────────────────────────────
//
// Castagnoli (0x82F63B78), NOT the far more common IEEE polynomial. Getting this wrong makes every
// valid seed look corrupt.
const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32c(bytes: Uint8Array): number {
let c = 0xffffffff;
for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── mnemonic <-> bytes ───────────────────────────────────────────────────────────────────────────
const WORD_INDEX = new Map<string, number>(wordlist.map((w, i) => [w, i]));
/**
* Pack 24 words into the 33 raw bytes, 11 bits each. Returns null when any word is outside the
* wordlist or the count is wrong — i.e. when this cannot be an aezeed phrase at all.
*/
export function mnemonicToCipherSeedBytes(words: string[]): Uint8Array | null {
if (words.length !== 24) return null;
const bits: number[] = [];
for (const word of words) {
const index = WORD_INDEX.get(word);
if (index === undefined) return null;
for (let b = 10; b >= 0; b--) bits.push((index >> b) & 1);
}
const bytes = new Uint8Array(ENCIPHERED_SIZE);
for (let i = 0; i < ENCIPHERED_SIZE; i++) {
let v = 0;
for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b]!;
bytes[i] = v;
}
return bytes;
}
/**
* Whether a phrase is an aezeed cipher seed. Cheap, offline, and decisive: it checks the version byte
* and the CRC32C, so it never touches scrypt or the passphrase. Safe to run on every phrase the owner
* types, and safe to ship to a client — it reveals nothing and needs no secret.
*/
export function isAezeedPhrase(words: string[]): boolean {
const bytes = mnemonicToCipherSeedBytes(words);
if (!bytes) return false;
if (bytes[0] !== CIPHER_SEED_VERSION) return false;
return crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) === readU32BE(bytes, CHECKSUM_OFFSET);
}
const readU32BE = (b: Uint8Array, off: number): number =>
((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
// ── decipher ─────────────────────────────────────────────────────────────────────────────────────
export type CipherSeed = {
internalVersion: number;
/** Days since the Bitcoin genesis block. */
birthday: number;
/** The BIP32 master seed. 16 bytes. Caller is responsible for zeroing it. */
entropy: Uint8Array;
};
export class AezeedError extends Error {
constructor(
message: string,
readonly code: 'BAD_VERSION' | 'BAD_CHECKSUM' | 'BAD_PASSPHRASE' | 'BAD_LENGTH',
) {
super(message);
this.name = 'AezeedError';
}
}
/** Convert a birthday (days since genesis) to a date — useful as a rescan floor. */
export function birthdayToDate(birthday: number): Date {
return new Date(BITCOIN_GENESIS + birthday * ONE_DAY_MS);
}
/**
* Decipher a 33-byte aezeed. Throws AezeedError with a code the caller can turn into a useful message —
* in particular BAD_PASSPHRASE, which is the difference between "your words are wrong" (they are not)
* and "this seed has a passphrase set and we need it".
*/
export async function decipherCipherSeed(bytes: Uint8Array, passphrase: string): Promise<CipherSeed> {
if (bytes.length !== ENCIPHERED_SIZE) {
throw new AezeedError(`aezeed must be ${ENCIPHERED_SIZE} bytes, got ${bytes.length}`, 'BAD_LENGTH');
}
if (bytes[0] !== CIPHER_SEED_VERSION) {
throw new AezeedError(`unsupported aezeed version ${bytes[0]}`, 'BAD_VERSION');
}
if (crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) !== readU32BE(bytes, CHECKSUM_OFFSET)) {
throw new AezeedError('aezeed checksum mismatch', 'BAD_CHECKSUM');
}
const salt = Buffer.from(bytes.subarray(SALT_OFFSET, SALT_OFFSET + SALT_SIZE));
const key = await scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: SCRYPT_MAXMEM,
});
// Associated data is the version byte followed by the salt, so a seed cannot be replayed under a
// different version or salt.
const ad = new Uint8Array(1 + SALT_SIZE);
ad[0] = CIPHER_SEED_VERSION;
ad.set(salt, 1);
try {
const plaintext = aezDecrypt(key, null, [ad], CIPHERTEXT_EXPANSION, bytes.subarray(1, SALT_OFFSET));
if (!plaintext || plaintext.length !== PLAINTEXT_SIZE) {
throw new AezeedError('incorrect seed passphrase', 'BAD_PASSPHRASE');
}
return {
internalVersion: plaintext[0]!,
birthday: (plaintext[1]! << 8) | plaintext[2]!,
entropy: plaintext.slice(3, 3 + ENTROPY_SIZE),
};
} finally {
key.fill(0);
}
}
/** Convenience: words straight to a deciphered seed. */
export async function decipherMnemonic(words: string[], passphrase = DEFAULT_AEZEED_PASSPHRASE): Promise<CipherSeed> {
const bytes = mnemonicToCipherSeedBytes(words);
if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH');
return decipherCipherSeed(bytes, passphrase);
}