import lnd aezeed cipher seeds alongside bip39

An LND seed is not a BIP39 mnemonic. It shares the 24-word shape and the English wordlist,
which is exactly why it fails confusingly: the words validate as plausible input, the
checksum does not, and the user is told their own seed is invalid.

aezeed is a different construction — a 19-byte payload (version, birthday, 16-byte entropy)
sealed with AEZ under scrypt(passphrase, salt), with the salt carried in the mnemonic
itself. So it needs its own decipher, not a flag on the BIP39 path. The vendored aez/aezeed
implementation under sidecar/wallet/aezeed does that, and the recovered entropy becomes the
BIP32 root the same way BIP39 output does.

The seed envelope gains a kind ('bip39' | 'aezeed') so an unlock knows which derivation to
run rather than guessing from word count, which cannot distinguish them.

Also note the aezeed passphrase is not a BIP39 passphrase: it decrypts the seed rather than
salting the derivation, so a wrong one fails the checksum outright instead of silently
producing a different wallet. The UI can therefore tell the user they typed it wrong, which
is not possible for BIP39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:12:35 +00:00
co-authored by Claude Opus 5
parent fb70c83ae7
commit 2647510965
7 changed files with 1524 additions and 47 deletions
+380
View File
@@ -0,0 +1,380 @@
import { blake2b } from '@noble/hashes/blake2.js';
// AEZ v5 — the wide-block cipher LND's aezeed uses to encrypt a cipher seed.
//
// SCOPE, deliberately narrow. This is a DECRYPT-ONLY implementation of the SHORT-INPUT path:
//
// - No encrypt. Officer never mints aezeed seeds — it only imports ones LND/Zeus already created.
// A new Officer wallet gets a BIP39 mnemonic, which is the portable format.
// - No aezCore. An aezeed ciphertext is a FIXED 23 bytes (33 total, minus the 1-byte version, the
// 5-byte salt and the 4-byte CRC), and AEZ dispatches on length: anything under 32 bytes takes the
// `aezTiny` Feistel path. `aezCore` is unreachable here, so shipping it would mean carrying ~120
// lines of untested cipher in a seed-recovery path. `decrypt` throws rather than guess.
// - No aezPRF. Only reached when the ciphertext is exactly `tau` bytes (empty plaintext).
//
// Every line below is exercised by the tests. That is the point of the narrowness — a subtle bug in
// unreachable crypto is a bug nobody finds until it eats someone's wallet.
//
// Ported from the reference implementation (Yawning/aez, as used by lnd/aezeed). Faithful to the point
// of preserving its integer-truncation quirks; where the original relied on a JavaScript accident, the
// comment says so.
const BLOCK_SIZE = 16;
const EXTRACTED_KEY_SIZE = 48;
const ZERO = new Uint8Array(BLOCK_SIZE);
// ── AES round tables ─────────────────────────────────────────────────────────────────────────────
//
// AEZ is built on reduced-round AES (4 and 10 rounds, always with MixColumns and no key schedule —
// the "keys" are just the extracted key material). The four 256-entry T-tables are pure functions of
// the AES S-box: TE0[i] = [2·S[i], S[i], S[i], 3·S[i]] big-endian, and TE1..TE3 are TE0 rotated right
// by 8, 16 and 24 bits. Deriving them costs microseconds once and removes 1024 hand-copied constants
// from a file where a single wrong digit silently produces the wrong wallet.
// prettier-ignore
const SBOX_HEX =
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' +
'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' +
'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' +
'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' +
'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' +
'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' +
'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' +
'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
const SBOX = Uint8Array.from(SBOX_HEX.match(/../g)!, (h) => parseInt(h, 16));
/** GF(2^8) doubling — the `xtime` of the AES spec. */
const xtime = (b: number): number => ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff;
function buildTables(): [Uint32Array, Uint32Array, Uint32Array, Uint32Array] {
const te0 = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const s = SBOX[i]!;
te0[i] = ((xtime(s) << 24) | (s << 16) | (s << 8) | (xtime(s) ^ s)) >>> 0;
}
const ror8 = (x: number): number => ((x >>> 8) | (x << 24)) >>> 0;
const te1 = te0.map(ror8);
const te2 = te1.map(ror8);
const te3 = te2.map(ror8);
return [te0, te1, te2, te3];
}
const [TE0, TE1, TE2, TE3] = buildTables();
// ── block helpers ────────────────────────────────────────────────────────────────────────────────
const block = (size = BLOCK_SIZE): Uint8Array => new Uint8Array(size);
function xor16(a: Uint8Array, b: Uint8Array, dst: Uint8Array): void {
for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]!;
}
function xor4x16(a: Uint8Array, b: Uint8Array, c: Uint8Array, d: Uint8Array, dst: Uint8Array): void {
for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]! ^ c[i]! ^ d[i]!;
}
/** Multiply a block by x in GF(2^128), in place. */
function doubleBlock(p: Uint8Array): void {
const carry = p[0]!;
for (let i = 0; i < 15; i++) p[i] = ((p[i]! << 1) | (p[i + 1]! >> 7)) & 0xff;
p[15] = ((p[15]! << 1) ^ (carry >> 7 ? 135 : 0)) & 0xff;
}
/** Multiply a block by the integer `x` in GF(2^128) — repeated doubling, accumulating odd bits. */
function multBlock(x: number, src: Uint8Array, dst: Uint8Array): void {
const t = block();
const r = block();
t.set(src);
let n = x;
while (n !== 0) {
if (n & 1) xor16(r, t, r);
doubleBlock(t);
n >>= 1;
}
dst.set(r);
}
const readU32BE = (b: Uint8Array, off: number): number =>
((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
function writeU32BE(b: Uint8Array, off: number, v: number): void {
b[off] = (v >>> 24) & 0xff;
b[off + 1] = (v >>> 16) & 0xff;
b[off + 2] = (v >>> 8) & 0xff;
b[off + 3] = v & 0xff;
}
// ── reduced-round AES ────────────────────────────────────────────────────────────────────────────
type AesKeys = { aes4: Uint32Array; aes10: Uint32Array };
/** Lay the 48-byte extracted key out into the fixed round-key schedules AEZ prescribes. */
function aesKeys(extracted: Uint8Array): AesKeys {
const k = new Uint32Array(12);
for (let i = 0; i < 12; i++) k[i] = readU32BE(extracted, 4 * i);
const aes10 = new Uint32Array(40);
aes10.set(k, 0);
aes10.set(k, 12);
aes10.set(k, 24);
aes10.set(k.subarray(0, 4), 36);
const aes4 = new Uint32Array(16);
aes4.set(k.subarray(4, 8), 0);
aes4.set(k.subarray(0, 4), 4);
aes4.set(k.subarray(8, 12), 8);
return { aes4, aes10 };
}
/** AES rounds with MixColumns applied on every round, including the last (as AEZ specifies). */
function rounds(keys: Uint32Array, count: number, b: Uint8Array): void {
let s0 = readU32BE(b, 0);
let s1 = readU32BE(b, 4);
let s2 = readU32BE(b, 8);
let s3 = readU32BE(b, 12);
for (let r = 0; r < count; r++) {
const o = r * 4;
const t0 =
TE0[(s0 >>> 24) & 0xff]! ^ TE1[(s1 >>> 16) & 0xff]! ^ TE2[(s2 >>> 8) & 0xff]! ^ TE3[s3 & 0xff]! ^ keys[o]!;
const t1 =
TE0[(s1 >>> 24) & 0xff]! ^ TE1[(s2 >>> 16) & 0xff]! ^ TE2[(s3 >>> 8) & 0xff]! ^ TE3[s0 & 0xff]! ^ keys[o + 1]!;
const t2 =
TE0[(s2 >>> 24) & 0xff]! ^ TE1[(s3 >>> 16) & 0xff]! ^ TE2[(s0 >>> 8) & 0xff]! ^ TE3[s1 & 0xff]! ^ keys[o + 2]!;
const t3 =
TE0[(s3 >>> 24) & 0xff]! ^ TE1[(s0 >>> 16) & 0xff]! ^ TE2[(s1 >>> 8) & 0xff]! ^ TE3[s2 & 0xff]! ^ keys[o + 3]!;
s0 = t0 >>> 0;
s1 = t1 >>> 0;
s2 = t2 >>> 0;
s3 = t3 >>> 0;
}
writeU32BE(b, 0, s0);
writeU32BE(b, 4, s1);
writeU32BE(b, 8, s2);
writeU32BE(b, 12, s3);
}
function aes4(k: AesKeys, j: Uint8Array, i: Uint8Array, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
xor4x16(j, i, l, src, dst);
rounds(k.aes4, 4, dst);
}
function aes10(k: AesKeys, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void {
xor16(src, l, dst);
rounds(k.aes10, 10, dst);
}
// ── AEZ state ────────────────────────────────────────────────────────────────────────────────────
type AezState = { I: Uint8Array[]; J: Uint8Array[]; L: Uint8Array[]; aes: AesKeys };
/** AEZ tolerates any key length by hashing it to 48 bytes; a 48-byte key is used verbatim. */
function extractKey(key: Uint8Array): Uint8Array {
return key.length === EXTRACTED_KEY_SIZE ? key : blake2b(key, { dkLen: EXTRACTED_KEY_SIZE });
}
function init(key: Uint8Array): AezState {
const ext = extractKey(key);
const I = [block(), block()];
const J = [block(), block(), block()];
const L = Array.from({ length: 8 }, () => block());
I[0]!.set(ext.subarray(0, 16));
multBlock(2, I[0]!, I[1]!);
J[0]!.set(ext.subarray(16, 32));
multBlock(2, J[0]!, J[1]!);
multBlock(2, J[1]!, J[2]!);
// L[0] is deliberately left as the zero block — the reference never assigns it.
L[1]!.set(ext.subarray(32, 48));
multBlock(2, L[1]!, L[2]!);
xor16(L[2]!, L[1]!, L[3]!);
multBlock(2, L[2]!, L[4]!);
xor16(L[4]!, L[1]!, L[5]!);
multBlock(2, L[3]!, L[6]!);
xor16(L[6]!, L[1]!, L[7]!);
return { I, J, L, aes: aesKeys(ext) };
}
/** The AXU hash binding the tag length, the nonce and each associated-data string into `delta`. */
function aezHash(st: AezState, nonce: Uint8Array | null, ad: Uint8Array[], tauBits: number): Uint8Array {
const buf = block();
const sum = block();
const I = block();
const J = block();
writeU32BE(buf, 12, tauBits >>> 0);
xor16(st.J[0]!, st.J[1]!, J);
aes4(st.aes, J, st.I[1]!, st.L[1]!, buf, sum);
const nonceEmpty = !nonce || nonce.length === 0;
let n = nonce ?? new Uint8Array(0);
let nBytes = n.length;
I.set(st.I[1]!);
for (let i = 1; nBytes >= BLOCK_SIZE; i++, nBytes -= BLOCK_SIZE) {
aes4(st.aes, st.J[2]!, I, st.L[i % 8]!, n.subarray(0, BLOCK_SIZE), buf);
xor16(sum, buf, sum);
n = n.subarray(BLOCK_SIZE);
if (i % 8 === 0) doubleBlock(I);
}
if (nBytes > 0 || nonceEmpty) {
buf.fill(0);
if (!nonceEmpty) buf.set(n.subarray(0, nBytes));
buf[nBytes] = 0x80;
aes4(st.aes, st.J[2]!, st.I[0]!, st.L[0]!, buf, buf);
xor16(sum, buf, sum);
}
ad.forEach((entry, k) => {
let p = entry ?? new Uint8Array(0);
const adEmpty = p.length === 0;
let bytes = p.length;
I.set(st.I[1]!);
multBlock(5 + k, st.J[0]!, J);
for (let i = 1; bytes >= BLOCK_SIZE; i++, bytes -= BLOCK_SIZE) {
aes4(st.aes, J, I, st.L[i % 8]!, p.subarray(0, BLOCK_SIZE), buf);
xor16(sum, buf, sum);
p = p.subarray(BLOCK_SIZE);
if (i % 8 === 0) doubleBlock(I);
}
if (bytes > 0 || adEmpty) {
buf.fill(0);
if (!adEmpty) buf.set(p.subarray(0, bytes));
buf[bytes] = 0x80;
aes4(st.aes, J, st.I[0]!, st.L[0]!, buf, buf);
xor16(sum, buf, sum);
}
});
return sum;
}
/**
* The short-input (< 32 byte) balanced Feistel network. `d` is 0 to encipher, 1 to decipher — the only
* difference is which end of the round counter it starts from.
*/
function aezTiny(st: AezState, delta: Uint8Array, input: Uint8Array, d: 0 | 1, dst: Uint8Array): void {
const inBytes = input.length;
const buf = block(2 * BLOCK_SIZE);
const L = block();
const R = block();
const tmp = block();
let mask = 0x00;
let pad = 0x80;
// Integer halves. The reference indexes with `inBytes/2` and leans on truncation; spelling the two
// halves out makes the odd-length case readable instead of accidental.
const half = inBytes >> 1;
const halfUp = (inBytes + 1) >> 1;
let i = 7;
let roundCount: number;
if (inBytes === 1) roundCount = 24;
else if (inBytes === 2) roundCount = 16;
else if (inBytes < 16) roundCount = 10;
else {
i = 6;
roundCount = 8;
}
L.set(input.subarray(0, halfUp));
R.set(input.subarray(half, half + halfUp));
if (inBytes & 1) {
// Odd length: the two halves overlap by a nibble, so shift R left by 4 bits and switch to
// nibble-granular padding.
for (let k = 0; k < half; k++) R[k] = ((R[k]! << 4) | (R[k + 1]! >> 4)) & 0xff;
R[half] = (R[half]! << 4) & 0xff;
pad = 0x08;
mask = 0xf0;
}
let j: number;
let step: number;
if (d !== 0) {
if (inBytes < 16) {
buf.set(input.subarray(0, BLOCK_SIZE));
buf[0] = buf[0]! | 0x80;
xor16(delta, buf, buf);
aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
L[0] = L[0]! ^ (tmp[0]! & 0x80);
}
j = roundCount - 1;
step = -1;
} else {
j = 0;
step = 1;
}
for (let k = 0; k < roundCount / 2; k++, j += step * 2) {
buf.fill(0, 0, BLOCK_SIZE);
buf.set(R.subarray(0, halfUp));
buf[half] = (buf[half]! & mask) | pad;
xor16(buf, delta, buf);
buf[15] = buf[15]! ^ (j & 0xff);
aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
xor16(L, tmp, L);
buf.fill(0, 0, BLOCK_SIZE);
buf.set(L.subarray(0, halfUp));
buf[half] = (buf[half]! & mask) | pad;
xor16(buf, delta, buf);
buf[15] = buf[15]! ^ ((j + step) & 0xff);
aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp);
xor16(R, tmp, R);
}
buf.set(R.subarray(0, half), 0);
buf.set(L.subarray(0, halfUp), half);
if (inBytes & 1) {
for (let k = inBytes - 1; k > half; k--) buf[k] = ((buf[k]! >> 4) | (buf[k - 1]! << 4)) & 0xff;
buf[half] = (L[0]! >> 4) | (R[half]! & 0xf0);
}
dst.set(buf.subarray(0, inBytes));
if (inBytes < 16 && d === 0) {
buf.fill(0, inBytes, BLOCK_SIZE);
buf[0] = buf[0]! | 0x80;
xor16(delta, buf, buf);
aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp);
dst[0] = dst[0]! ^ (tmp[0]! & 0x80);
}
}
/**
* AEZ decrypt-and-verify. Returns the plaintext, or `null` when the `tau`-byte authenticator does not
* check out — which is the signal that the key (and therefore the seed passphrase) is wrong.
*
* Throws for inputs outside the aezeed envelope rather than silently taking an untested path; see the
* scope note at the top of this file.
*/
export function aezDecrypt(
key: Uint8Array,
nonce: Uint8Array | null,
ad: Uint8Array[],
tau: number,
ciphertext: Uint8Array,
): Uint8Array | null {
if (ciphertext.length >= 32 || ciphertext.length <= tau) {
throw new Error(`aez: unsupported ciphertext length ${ciphertext.length} (decrypt is short-input only)`);
}
const st = init(key);
const delta = aezHash(st, nonce, ad, tau * 8);
const x = new Uint8Array(ciphertext.length);
aezTiny(st, delta, ciphertext, 1, x);
// Constant-time-ish: fold every authenticator byte before deciding, so the comparison does not
// short-circuit on the first mismatch.
let sum = 0;
for (let i = 0; i < tau; i++) sum |= x[ciphertext.length - tau + i]!;
if (sum !== 0) return null;
return x.slice(0, ciphertext.length - tau);
}
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import {
AezeedError,
birthdayToDate,
decipherMnemonic,
isAezeedPhrase,
mnemonicToCipherSeedBytes,
} from './cipher-seed';
// Fixtures were produced by the `aezeed` npm package (an INDEPENDENT implementation, different author
// and codebase, itself matching lnd/aezeed). Deterministic entropy and salt make them reproducible, so
// agreement here is genuine cross-implementation evidence rather than this port agreeing with itself.
//
// entropy 000102...0f, salt 0102030405, internalVersion 0, birthday 5593
const ENTROPY_HEX = '000102030405060708090a0b0c0d0e0f';
const DEFAULT_PW =
'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
const CUSTOM_PW =
'above shop hair museum laugh diamond win skin habit inspire hero box category crime boss black nest useless dog alcohol doctor radar urban split';
const CUSTOM_PASSPHRASE = 'correct horse battery staple';
// entropy ffee...00, salt a1b2c3d4e5, internalVersion 1, birthday 0
const BIRTHDAY_ZERO =
'abstract three say vacant about cable tent neck other jeans certain accident arrest market couch front inflict laptop hole marble exact okay owner number';
const split = (phrase: string): string[] => phrase.split(' ');
const hex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
describe('isAezeedPhrase', () => {
test('accepts aezeed cipher seeds', () => {
expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(true);
expect(isAezeedPhrase(split(CUSTOM_PW))).toBe(true);
expect(isAezeedPhrase(split(BIRTHDAY_ZERO))).toBe(true);
});
test('is passphrase-independent — structure only, no scrypt', () => {
// Both fixtures hold the SAME entropy under different passphrases. Detection must not care.
expect(isAezeedPhrase(split(DEFAULT_PW))).toBe(isAezeedPhrase(split(CUSTOM_PW)));
});
test('rejects genuine BIP39 mnemonics', () => {
// The whole point: a 24-word BIP39 phrase is also 33 bytes, so only the CRC separates them.
for (let i = 0; i < 25; i++) {
expect(isAezeedPhrase(split(generateMnemonic(wordlist, 256)))).toBe(false);
}
});
test('rejects wrong lengths and non-wordlist words', () => {
expect(isAezeedPhrase(split(DEFAULT_PW).slice(0, 12))).toBe(false);
expect(isAezeedPhrase([...split(DEFAULT_PW).slice(0, 23), 'notaword'])).toBe(false);
expect(isAezeedPhrase([])).toBe(false);
});
test('rejects a single transposed word', () => {
const words = split(DEFAULT_PW);
const swapped = [...words];
swapped[3] = words[4]!;
swapped[4] = words[3]!;
expect(isAezeedPhrase(swapped)).toBe(false);
});
});
describe('decipherMnemonic', () => {
test('recovers entropy under the default passphrase', async () => {
const seed = await decipherMnemonic(split(DEFAULT_PW));
expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
expect(seed.entropy).toHaveLength(16);
expect(seed.internalVersion).toBe(0);
expect(seed.birthday).toBe(5593);
});
test('recovers the same entropy under a custom passphrase', async () => {
const seed = await decipherMnemonic(split(CUSTOM_PW), CUSTOM_PASSPHRASE);
expect(hex(seed.entropy)).toBe(ENTROPY_HEX);
});
test('decodes internalVersion 1 and a zero birthday', async () => {
const seed = await decipherMnemonic(split(BIRTHDAY_ZERO));
expect(hex(seed.entropy)).toBe('ffeeddccbbaa99887766554433221100');
expect(seed.internalVersion).toBe(1);
expect(seed.birthday).toBe(0);
});
test('a wrong passphrase fails the AEZ tag rather than returning junk', async () => {
// This is the property that makes the whole port safe: a bad key CANNOT yield plausible-looking
// entropy, so we can never silently derive the wrong wallet.
const err = await decipherMnemonic(split(CUSTOM_PW), 'not the passphrase').catch((e) => e);
expect(err).toBeInstanceOf(AezeedError);
expect(err.code).toBe('BAD_PASSPHRASE');
});
test('the default passphrase is not silently accepted for a custom-passphrase seed', async () => {
const err = await decipherMnemonic(split(CUSTOM_PW)).catch((e) => e);
expect(err.code).toBe('BAD_PASSPHRASE');
});
test('a corrupted checksum is caught before scrypt', async () => {
const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW))!;
bytes[32] = bytes[32]! ^ 0xff;
const { decipherCipherSeed } = await import('./cipher-seed');
const err = await decipherCipherSeed(bytes, 'aezeed').catch((e) => e);
expect(err.code).toBe('BAD_CHECKSUM');
});
test('rejects a non-aezeed phrase', async () => {
const err = await decipherMnemonic(split(generateMnemonic(wordlist, 256))).catch((e) => e);
expect(err).toBeInstanceOf(AezeedError);
});
});
describe('framing', () => {
test('24 words pack into exactly 33 bytes', () => {
const bytes = mnemonicToCipherSeedBytes(split(DEFAULT_PW));
expect(bytes).toHaveLength(33);
expect(bytes![0]).toBe(0); // aezeed version
});
test('birthday maps to a real date', () => {
// 5593 days after the genesis block.
expect(birthdayToDate(5593).toISOString().slice(0, 10)).toBe('2024-04-27');
expect(birthdayToDate(0).toISOString().slice(0, 10)).toBe('2009-01-03');
});
});
@@ -0,0 +1,202 @@
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);
}
+100
View File
@@ -180,6 +180,106 @@ describe('UnlockSession', () => {
);
});
describe('aezeed seeds', () => {
// The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy
// is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third
// specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer.
const AEZEED =
'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
const BIP32_VECTOR_1_FINGERPRINT = '3442193e';
test(
'seals as v2 and derives the BIP32 vector root',
async () => {
const env = await sealSeed(AEZEED, PASS);
expect(env.v).toBe(2);
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
},
SLOW,
);
test(
'does NOT derive what the BIP39 path would have',
async () => {
// The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist
// words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely
// wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan.
const { mnemonicToSeedSync } = await import('@scure/bip39');
const { HDKey } = await import('@scure/bip32');
const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED));
const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer))
.reverse()
.toString('hex');
expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT);
const env = await sealSeed(AEZEED, PASS);
const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin');
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
},
SLOW,
);
test(
'the words survive an export round trip',
async () => {
const env = await sealSeed(AEZEED, PASS);
expect(await exportMnemonic(env, PASS)).toBe(AEZEED);
},
SLOW,
);
test(
'unlocking derives the same root as the stored xpub',
async () => {
const env = await sealSeed(AEZEED, PASS);
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
const s = new UnlockSession(2);
await s.unlock(env, PASS, 60);
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
s.lock();
},
SLOW,
);
test(
'a wrong seed passphrase is refused at seal time, not silently accepted',
async () => {
// AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal
// itself later as an empty balance — the single most expensive way to find out.
await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i);
},
SLOW,
);
test(
'a phrase that is neither BIP39 nor aezeed is still rejected',
async () => {
const bad = AEZEED.split(' ');
[bad[3], bad[4]] = [bad[4]!, bad[3]!];
await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i);
},
SLOW,
);
test(
'rotation preserves the aezeed kind and its derivation',
async () => {
const env = await sealSeed(AEZEED, PASS);
const next = 'an entirely different passphrase';
const rotated = await changePassphrase(env, PASS, next);
expect(rotated.v).toBe(2);
const after = await deriveAccountXpubs(rotated, next, 'bitcoin');
expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
},
SLOW,
);
});
describe('generateSeed', () => {
test(
'produces a distinct, valid mnemonic that survives a round trip',
+93 -15
View File
@@ -4,6 +4,7 @@ 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.
@@ -52,7 +53,20 @@ 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;
// 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;
@@ -60,9 +74,12 @@ export type SeedEnvelope = {
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. */
/** base64(iv[12] | tag[16] | ciphertext) — the seed phrase, encrypted under the DEK. */
seed: string;
/** Whether a BIP39 passphrase (the "25th word") is part of this seed. Affects derivation, not secrecy. */
/**
* Whether a seed passphrase is part of this seed — the BIP39 "25th word", or the aezeed passphrase.
* Affects derivation, not secrecy.
*/
hasBip39Passphrase: boolean;
};
@@ -100,9 +117,35 @@ async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
}
/**
* 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.
* 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,
@@ -110,9 +153,18 @@ export async function sealSeed(
bip39Passphrase?: string,
): Promise<SeedEnvelope> {
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
if (!validateMnemonic(normalized, wordlist)) {
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');
}
@@ -122,11 +174,14 @@ export async function sealSeed(
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');
const payload = Buffer.from(
JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '', kind }),
'utf8',
);
try {
return {
v: ENVELOPE_VERSION,
v: kind === 'aezeed' ? ENVELOPE_VERSION_AEZEED : ENVELOPE_VERSION_BIP39,
salt: salt.toString('base64'),
wrappedDek: gcmEncrypt(kek, dek),
seed: gcmEncrypt(dek, payload),
@@ -144,10 +199,10 @@ export function generateSeed(words: 12 | 24 = 24): string {
return generateMnemonic(wordlist, words === 12 ? 128 : 256);
}
type OpenedSeed = { mnemonic: string; bip39Passphrase: string };
type OpenedSeed = { mnemonic: string; bip39Passphrase: string; kind?: SeedKind };
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
if (env.v !== ENVELOPE_VERSION) {
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'));
@@ -158,7 +213,10 @@ async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise
dek = gcmDecrypt(kek, env.wrappedDek);
const payload = gcmDecrypt(dek, env.seed);
try {
return JSON.parse(payload.toString('utf8')) as OpenedSeed;
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);
}
@@ -184,7 +242,27 @@ export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): str
return `m/${bip}'/${coinType(network)}'/${account}'`;
}
function rootFromSeed(opened: OpenedSeed): HDKey {
/**
* 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);
@@ -203,7 +281,7 @@ export async function deriveAccountXpubs(
network: BitcoinNetwork,
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
const opened = await openEnvelope(env, ownerPassphrase);
const root = rootFromSeed(opened);
const root = await rootFromSeed(opened);
try {
const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
.reverse()
@@ -285,7 +363,7 @@ export class UnlockSession {
attempts.delete(this.walletId);
this.lock(); // replace any existing session rather than leaking the old root
this.root = rootFromSeed(opened);
this.root = await rootFromSeed(opened);
// Drop the words immediately — the root key is all any signing operation needs.
opened.mnemonic = '';
opened.bip39Passphrase = '';