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>
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
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');
|
|
});
|
|
});
|