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