Files
platform/src/servers/sidecar/wallet/aezeed/aezeed.test.ts
T
pastilhasandClaude Opus 5 2647510965 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>
2026-07-31 15:12:35 +00:00

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');
});
});