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:
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user