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; 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(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 { 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 { const bytes = mnemonicToCipherSeedBytes(words); if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH'); return decipherCipherSeed(bytes, passphrase); }