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 = '';
@@ -15,10 +15,19 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
import { useWalletConfig, useWalletLifecycle } from '../useWalletData';
import { errorMessage, useWalletConfig, useWalletLifecycle } from '../useWalletData';
import { SeedBackupDialog } from './SeedBackupDialog';
import { Textarea } from '@/components/ui/textarea';
import {
SeedPhraseInput,
SEED_LENGTHS,
findOrderFix,
isAezeed,
normalizePhrase,
phraseProblem,
type SeedLength,
} from './SeedPhraseInput';
// Add a wallet. Two genuinely different shapes behind one form:
//
@@ -67,7 +76,15 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const [kind, setKind] = useState<BackendKind>('onchain');
const [words, setWords] = useState<12 | 24>(24);
const [importing, setImporting] = useState(false);
const [mnemonicInput, setMnemonicInput] = useState('');
// One entry per word rather than one string: the boxes ARE the state, so a wrong word is visible where
// it is rather than somewhere in a wall of text. Joined back into a phrase only at submit.
const [importLength, setImportLength] = useState<SeedLength>(24);
const [importWords, setImportWords] = useState<string[]>(() => Array(24).fill(''));
// A plain-text escape hatch alongside the boxes. Both write the same `importWords`, so validation and
// submit never care which was used — but when a phrase the owner knows is right will not validate, this
// is what separates "my per-box entry mangled it" from "the phrase really is different".
const [asText, setAsText] = useState(false);
const [importText, setImportText] = useState('');
// Both of these are secrets in flight. They exist in this component's state, go into the request body,
// and are cleared the moment the request settles — never a store, never a query key, never the URL.
const [passphrase, setPassphrase] = useState('');
@@ -84,11 +101,38 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const storeKeyMissing = config != null && !config.storeKeyConfigured;
const isOnchain = kind === 'onchain';
/** Anything the owner would be upset to retype — guards the incidental-dismiss paths below. */
const hasTypedSecret =
importWords.some((w) => w.trim()) || passphrase.length > 0 || Object.values(remoteConfig).some((v) => v?.trim());
/** Grow or shrink the grid, keeping whatever has already been typed. */
const resizeImport = (n: SeedLength) => {
setImportLength(n);
setImportWords((prev) => Array.from({ length: n }, (_, i) => prev[i] ?? ''));
};
/** Text mode is the source of truth while it is open; the boxes mirror it word for word. */
const applyText = (text: string) => {
setImportText(text);
const parts = normalizePhrase(text);
if ((SEED_LENGTHS as readonly number[]).includes(parts.length)) setImportLength(parts.length as SeedLength);
setImportWords(parts);
};
/** Switching modes must not silently drop or reorder anything the owner already entered. */
const toggleTextMode = (next: boolean) => {
if (next) setImportText(importWords.filter(Boolean).join(' '));
else setImportWords((prev) => Array.from({ length: importLength }, (_, i) => prev[i] ?? ''));
setAsText(next);
};
const clearSecrets = () => {
setPassphrase('');
setConfirmPassphrase('');
setBip39Passphrase('');
setMnemonicInput('');
// Functional, so it blanks whatever length the grid currently is without reading stale state.
setImportWords((prev) => prev.map(() => ''));
setImportText('');
setRemoteConfig({});
};
@@ -99,6 +143,9 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
setImporting(false);
setMakeActive(true);
clearSecrets();
setAsText(false);
setImportLength(24);
setImportWords(Array(24).fill(''));
};
const close = () => {
@@ -109,38 +156,64 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
const configValid =
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
const canSubmit = !!name.trim() && passphraseValid && configValid && !storeKeyMissing && !create.isPending;
// Checked locally so a bad phrase never costs a round trip. The sidecar still validates independently;
// this only means the owner finds out instantly, and precisely, instead of via a generic 400.
const seedProblem = isOnchain && importing ? phraseProblem(importWords) : null;
// Only worth computing for the one failure a human review cannot catch: right words, wrong order.
const orderFix = seedProblem?.kind === 'checksum' ? findOrderFix(importWords) : null;
// A valid phrase can be either format, and the two are visually identical, so say which one this is.
// It also changes what the passphrase field below means, which the owner has no way to guess.
const aezeed = isOnchain && importing && !seedProblem && isAezeed(importWords.map((w) => w.trim().toLowerCase()));
const canSubmit =
!!name.trim() && passphraseValid && configValid && !seedProblem && !storeKeyMissing && !create.isPending;
// A disabled button with no stated reason is a dead end — especially here, where the blocker is often
// the passphrase fields further down while the owner is staring at the phrase.
const blockReason = !name.trim()
? 'Give the wallet a name.'
: seedProblem
? 'The recovery phrase above is not complete or not valid.'
: !passphraseValid
? 'Set a passphrase of at least 8 characters, twice.'
: !configValid
? 'Fill in the connection details.'
: null;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
let result;
try {
const result = await create.mutateAsync(
result = await create.mutateAsync(
isOnchain
? {
name: name.trim(),
kind,
passphrase,
words,
mnemonic: importing ? mnemonicInput.trim() : undefined,
mnemonic: importing ? importWords.map((w) => w.trim()).join(' ') : undefined,
bip39Passphrase: bip39Passphrase || undefined,
makeActive,
}
: { name: name.trim(), kind, config: remoteConfig, makeActive },
);
} catch {
// KEEP EVERYTHING TYPED. A rejected phrase is precisely the moment the owner needs their other 23
// words back — clearing here means retyping the whole thing to fix one box, which is both miserable
// and a good way to introduce a second mistake. `create.onError` already toasts, and the error is
// rendered inline below. Nothing lingers regardless: cancel or close still runs clearSecrets().
return;
}
const created = result.wallet;
if (result.mnemonic) {
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
} else {
navigate(walletSectionPath(section, created.id));
close();
}
} finally {
// Whatever happened, no secret survives the submit.
const created = result.wallet;
if (result.mnemonic) {
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
clearSecrets();
} else {
navigate(walletSectionPath(section, created.id));
close();
}
};
@@ -154,7 +227,20 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
return (
<>
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
{/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */}
<DialogContent
className={`max-h-[85vh] overflow-y-auto ${isOnchain && importing ? 'max-w-2xl' : 'max-w-lg'}`}
// A half-typed recovery phrase must not be destroyed by a misplaced click on the overlay or a
// stray Escape (dismissing an error toast, say). Those gestures are cheap and reversible for an
// ordinary form; here they cost the owner all 24 words. Cancel and the X still close normally —
// the point is that discarding becomes deliberate, not incidental.
onInteractOutside={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
onEscapeKeyDown={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
>
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Add a wallet</DialogTitle>
@@ -215,19 +301,116 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
{importing ? (
<div className="space-y-1.5">
<Label htmlFor="wallet-new-mnemonic">Recovery phrase</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Recovery phrase</Label>
<SegButton
active={asText}
onClick={() => toggleTextMode(!asText)}
label={asText ? 'Use word boxes' : 'Paste as text'}
/>
</div>
{!asText && (
<div className="flex flex-wrap gap-1.5">
{SEED_LENGTHS.map((n) => (
<SegButton
key={n}
active={importLength === n}
onClick={() => resizeImport(n)}
label={`${n}`}
/>
))}
</div>
)}
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
never echoed back by the sidecar either. */}
<Textarea
id="wallet-new-mnemonic"
rows={3}
autoComplete="off"
spellCheck={false}
value={mnemonicInput}
placeholder="twelve or twenty-four words, separated by spaces"
onChange={(ev) => setMnemonicInput(ev.target.value)}
className="font-mono text-xs"
/>
{asText ? (
<Textarea
rows={4}
autoComplete="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
value={importText}
placeholder="Paste or type the whole phrase. Numbering, commas and line breaks are ignored."
onChange={(ev) => applyText(ev.target.value)}
className="font-mono text-xs"
/>
) : (
<SeedPhraseInput
words={importWords}
onChange={setImportWords}
onLengthDetected={(n) => setImportLength(n as SeedLength)}
/>
)}
{aezeed ? (
<div className="space-y-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<p className="text-xs">
<span className="font-medium text-emerald-600 dark:text-emerald-500">
Valid aezeed seed checksum verified in this browser.
</span>{' '}
This is not a BIP39 phrase. It is an <em>aezeed</em> cipher seed, the format Zeus's embedded
LND node produces: the same 2048-word list, so it looks identical, but encrypted and
checksummed differently. Officer imports it.
</p>
<p className="text-xs text-muted-foreground">
This recovers the <span className="font-medium">on-chain</span> wallet. Lightning channels
are not in the seed — those need a channel backup file and a running LND.
</p>
</div>
) : orderFix ? (
<div className="space-y-1.5 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<p className="text-xs">
<span className="font-medium">These are your words — the order is wrong.</span> Read down
the columns, they check out. Your backup is almost certainly printed as {orderFix.rows} rows
of {orderFix.cols}, running top-to-bottom, and was read left-to-right.
</p>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setImportWords(orderFix.words);
setImportText(orderFix.words.join(' '));
}}
>
Reorder to the valid phrase
</Button>
<p className="text-xs text-muted-foreground">
Check the result against your backup before continuing.
</p>
</div>
) : seedProblem?.kind === 'checksum' ? (
// The hardest case to act on, so it gets the longest explanation: every word is
// real, so nothing is marked red, and the only remaining faults are order or a
// word that is a near-miss for the right one.
<p className="text-xs text-destructive">
All {importWords.length} words are valid BIP39 words, but the phrase does not check out. A
word is in the wrong position, or one is a near-miss for the right one (<em>fan</em> where the
backup says <em>fancy</em>). Re-read it against your backup in order.
</p>
) : seedProblem?.kind === 'unknown' ? (
<p className="text-xs text-destructive">
Not in the BIP39 word list: {seedProblem.boxes.length === 1 ? 'box' : 'boxes'}{' '}
{seedProblem.boxes.join(', ')}.
</p>
) : seedProblem?.kind === 'incomplete' ? (
<p className="text-xs text-muted-foreground">
{seedProblem.boxes.length} {seedProblem.boxes.length === 1 ? 'word' : 'words'} still empty.
</p>
) : seedProblem?.kind === 'length' ? (
<p className="text-xs text-destructive">
{seedProblem.count} words. A BIP39 phrase must be 12, 15, 18, 21 or 24.
</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-500">
Valid phrase — checksum verified in this browser.
</p>
)}
<p className="text-xs text-muted-foreground">
Type a few letters and pick from the list, or paste the whole phrase into the first box.
</p>
</div>
) : (
<div className="space-y-1.5">
@@ -272,8 +455,14 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
<p className="text-xs text-destructive">The two passphrases do not match.</p>
)}
{/* One field, two meanings, because the two seed formats each have exactly one such
secret and which one applies is decided by the phrase itself. Mislabelling it for an
aezeed would be actively harmful: leaving it blank there is correct and normal, while
typing the wallet passphrase into it fails the AEZ tag and looks like a bad seed. */}
<div className="space-y-1.5">
<Label htmlFor="wallet-new-bip39">BIP39 passphrase (optional)</Label>
<Label htmlFor="wallet-new-bip39">
{aezeed ? 'Seed passphrase (optional)' : 'BIP39 passphrase (optional)'}
</Label>
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
<Input
id="wallet-new-bip39"
@@ -283,8 +472,9 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
onChange={(ev) => setBip39Passphrase(ev.target.value)}
/>
<p className="text-xs text-muted-foreground">
A 25th word. It is part of the key, not a lock on it without it the phrase alone recovers a
different, empty wallet.
{aezeed
? 'Only if you set one when the aezeed seed was created. Almost nobody does leave it blank.'
: 'A 25th word. It is part of the key, not a lock on it without it the phrase alone recovers a different, empty wallet.'}
</p>
</div>
</>
@@ -315,6 +505,22 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
</label>
</div>
{/* The toast disappears; this does not. With the form no longer clearing on failure, the
owner needs the reason to stay put next to the boxes they are about to correct. */}
{/* Only while the current input still matches what was rejected. Without this the box from a
previous attempt hangs around after the phrase is corrected, which reads as "still broken"
when nothing is broken — and the mutation's error state does not clear itself. */}
{create.isError && !seedProblem && (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>{errorMessage(create.error, 'Could not create the wallet')}</span>
</div>
)}
{blockReason && !storeKeyMissing && (
<p className="mt-4 text-right text-xs text-muted-foreground">{blockReason}</p>
)}
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
@@ -0,0 +1,387 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// One input per BIP39 word, with dictionary autocomplete — the same shape Zeus and every other serious
// wallet uses for recovery, and for the same reason: a 24-word phrase typed into one freeform box is
// impossible to proofread, and a single mistyped word fails the checksum with no clue which one is wrong.
// Per-word boxes turn that into an immediately visible red border on word 17.
//
// SECRET IN FLIGHT. The words live in the parent's state and go straight into the request body. Nothing
// here persists, and every field carries the full set of autofill opt-outs (autoComplete="off" plus the
// 1Password/LastPass/Bitwarden ignore attributes) — a password manager silently capturing a seed phrase
// would be the single worst thing this component could do.
const WORDS_PER_ROW_HINT = 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';
// Generous, and the list scrolls. A small cap silently hides real words — "fa" alone has 22 matches and
// "fan" is the twelfth, so a cap of 6 makes a perfectly valid word look like it is not in the dictionary.
// Truncation here must never be how the owner learns whether their seed word exists.
const MAX_SUGGESTIONS = 64;
/** BIP39 permits these lengths. 12 and 24 dominate, but a restore must not be blocked by the others. */
export const SEED_LENGTHS = [12, 15, 18, 21, 24] as const;
export type SeedLength = (typeof SEED_LENGTHS)[number];
const VALID = new Set<string>(wordlist);
/**
* Wordlist entries starting with `prefix`, shortest first (alphabetical within a length). Empty prefix
* yields nothing — suggestions need intent.
*
* Shortest-first matters more than it looks. A fully typed word is the shortest thing that can match
* itself, so it always lands at position 1 — you never hunt for confirmation that your word exists, and
* the default highlight is always the exact match rather than some longer word that merely shares a
* prefix. It also front-loads the short words that alphabetical order buries: "fan" is 12th of 22 under
* "fa" alphabetically, but 2nd by length.
*
* Collect-then-sort, never truncate-then-sort: capping during the scan would drop short matches that
* happen to sort late. Scanning all 2048 entries per keystroke is free.
*/
function suggest(prefix: string): string[] {
if (!prefix) return [];
const matches = wordlist.filter((w) => w.startsWith(prefix));
matches.sort((a, b) => a.length - b.length || a.localeCompare(b));
return matches.slice(0, MAX_SUGGESTIONS);
}
/**
* Split arbitrary pasted text into candidate words.
*
* Deliberately brutal: BIP39 English words are pure a-z, so everything else is separator. That makes the
* paste survive the shapes real backups actually arrive in — numbered lists ("1. fan 2. zoo"), commas,
* hard line breaks, non-breaking spaces, smart quotes from a notes app. Without this, a numbered paste
* silently becomes twenty-four "words" of digits and the owner is left staring at a wall of red.
*/
export function normalizePhrase(text: string): string[] {
return text
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.filter(Boolean);
}
export type PhraseProblem =
/** Boxes still blank (1-based, for display). */
| { kind: 'incomplete'; boxes: number[] }
/** Boxes holding something that is not a BIP39 word at all (1-based). */
| { kind: 'unknown'; boxes: number[] }
/** Not a permitted BIP39 length. Only reachable by pasting an odd count. */
| { kind: 'length'; count: number }
/** Every word is real and the count is legal, but the checksum says the phrase is not this phrase. */
| { kind: 'checksum' };
/** CRC32-Castagnoli (poly 0x82F63B78) — the polynomial LND's aezeed uses. Not the IEEE one. */
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;
}
/**
* Zeus (and anything else running an embedded LND) does NOT hand out BIP39 mnemonics. It hands out
* *aezeed* cipher seeds: 24 words drawn from the very same BIP39 English wordlist, so they are visually
* indistinguishable, but the trailing bits are a CRC32-Castagnoli checksum instead of a SHA-256 one.
* They can never validate as BIP39, no matter how carefully the owner proofreads.
*
* Officer imports these — the sidecar deciphers the AEZ ciphertext and uses the 16 bytes inside as the
* BIP32 master seed directly. But BIP39 validation still fails on them, so without this check the owner
* would be told "invalid checksum" for a phrase that is perfectly correct.
*
* This mirrors `isAezeedPhrase` in the sidecar (`wallet/aezeed/cipher-seed.ts`). It is duplicated rather
* than shared because it must run in the browser, where nothing under `src/servers` is reachable — and
* it is pure structure, no secret and no key material, so it is safe to ship client-side. The sidecar
* still decides independently; this only makes the form honest before the round trip.
*
* Layout: version(1) + ciphertext(23) + salt(5) + crc32c(4) = 33 bytes = 24 x 11 bits.
* Verified against LND's own published test vector.
*/
export function isAezeed(words: string[]): boolean {
if (words.length !== 24) return false;
let bits = '';
for (const word of words) {
const index = wordlist.indexOf(word);
if (index < 0) return false;
bits += index.toString(2).padStart(11, '0');
}
const bytes = Uint8Array.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));
if (bytes[0] !== 0) return false; // aezeed CipherSeedVersion
const view = new DataView(bytes.buffer);
return crc32c(bytes.subarray(0, 29)) === view.getUint32(29, false);
}
/**
* What is wrong with the phrase, checked locally so the owner never spends a network round trip — or
* risks the form state — to learn they mistyped word 17. This mirrors what the sidecar does (BIP39
* checksum, else aezeed structure), just run earlier; the sidecar remains the authority and still
* rejects independently. Purely local: nothing is transmitted to validate.
*
* An aezeed phrase is NOT a problem — it is a supported second format, so it returns null here and the
* caller uses `isAezeed` separately to explain what it is looking at.
*/
export function phraseProblem(words: string[]): PhraseProblem | null {
const trimmed = words.map((w) => w.trim().toLowerCase());
const blank = trimmed.map((w, i) => (w ? -1 : i + 1)).filter((i) => i > 0);
if (blank.length) return { kind: 'incomplete', boxes: blank };
const unknown = trimmed.map((w, i) => (VALID.has(w) ? -1 : i + 1)).filter((i) => i > 0);
if (unknown.length) return { kind: 'unknown', boxes: unknown };
if (!(SEED_LENGTHS as readonly number[]).includes(trimmed.length)) {
return { kind: 'length', count: trimmed.length };
}
if (validateMnemonic(trimmed.join(' '), wordlist)) return null;
// Checked before the generic failure: an aezeed seed is a different supported format, not a typo.
if (isAezeed(trimmed)) return null;
return { kind: 'checksum' };
}
/**
* The words are all real and the checksum still fails — so look for the one mistake that is invisible
* when you proofread: right words, wrong order.
*
* Wallets print backups in a grid, and plenty of them run the phrase DOWN the columns (1-12 left, 13-24
* right). Read ACROSS instead and you get every word present, every word spelled correctly, every box
* green, and a phrase that is simply not yours. Nothing in a word-by-word review can catch it, because
* every word individually is fine.
*
* That misreading is exactly a matrix transpose, so it is cheap to test: for each rows x cols factoring
* of the length, un-transpose and re-check. A handful of validations, and it either finds the owner's
* real phrase or rules the theory out. Returns null when the order is not the problem.
*
* NOT a brute-force search. Trying every single-word substitution is useless here — the checksum is only
* 8 bits, so ~8 different words validate at EVERY position and it can localise nothing.
*/
export function findOrderFix(words: string[]): { rows: number; cols: number; words: string[] } | null {
const n = words.length;
for (let rows = 2; rows < n; rows++) {
if (n % rows) continue;
const cols = n / rows;
const fixed = new Array<string>(n);
for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) fixed[j * rows + i] = words[i * cols + j]!;
if (validateMnemonic(fixed.join(' '), wordlist)) return { rows, cols, words: fixed };
}
return null;
}
type SeedPhraseInputProps = {
words: string[];
onChange: (words: string[]) => void;
/**
* Fired when a pasted phrase is itself a valid BIP39 length that differs from the current box count,
* so the parent can resize instead of making the owner notice the mismatch and fix the toggle by hand.
*/
onLengthDetected?: (count: number) => void;
disabled?: boolean;
};
export const SeedPhraseInput = ({ words, onChange, onLengthDetected, disabled }: SeedPhraseInputProps) => {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const [focused, setFocused] = useState<number | null>(null);
const [highlight, setHighlight] = useState(0);
/** Whether the owner moved the highlight by hand — the difference between "chose that word" and
* "happened to have it highlighted". Reset on every keystroke and on leaving the box. */
const [arrowed, setArrowed] = useState(false);
const suggestions = useMemo(() => (focused == null ? [] : suggest(words[focused] ?? '')), [focused, words]);
// The list shrinks as you type, so a stale index can point past the end. Clamp at read time rather than
// trying to keep `highlight` in sync with every keystroke.
const safeHighlight = suggestions.length ? Math.min(highlight, suggestions.length - 1) : 0;
const highlightRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
highlightRef.current?.scrollIntoView({ block: 'nearest' });
}, [safeHighlight, focused]);
const setWord = (index: number, value: string) => {
const next = [...words];
next[index] = value;
onChange(next);
};
const focus = (index: number) => {
const el = refs.current[index];
if (!el) return;
el.focus();
el.select();
};
/** Accept a suggestion (or whatever is typed) and move on. The last box stays put. */
const commit = (index: number, value: string) => {
setWord(index, value);
setHighlight(0);
setArrowed(false);
if (index < words.length - 1) requestAnimationFrame(() => focus(index + 1));
};
// Pasting the whole phrase into any box is how people actually restore. Spread it across the boxes
// from here on rather than dumping 24 words into one field.
const handlePaste = (index: number, ev: React.ClipboardEvent<HTMLInputElement>) => {
const parts = normalizePhrase(ev.clipboardData.getData('text'));
if (parts.length < 2) return;
ev.preventDefault();
// A whole phrase pasted into box 1 of the wrong-size grid: resize rather than truncate. Only from the
// first box — pasting a fragment mid-phrase should never resize the form.
if (index === 0 && parts.length !== words.length && (SEED_LENGTHS as readonly number[]).includes(parts.length)) {
onLengthDetected?.(parts.length);
onChange(parts);
requestAnimationFrame(() => focus(parts.length - 1));
return;
}
const next = [...words];
for (let i = 0; i < parts.length && index + i < next.length; i++) next[index + i] = parts[i]!;
onChange(next);
const landed = Math.min(index + parts.length, next.length - 1);
requestAnimationFrame(() => focus(landed));
};
const handleKeyDown = (index: number, ev: React.KeyboardEvent<HTMLInputElement>) => {
const current = words[index] ?? '';
if (ev.key === 'ArrowDown' && suggestions.length) {
ev.preventDefault();
setArrowed(true);
setHighlight((safeHighlight + 1) % suggestions.length);
return;
}
if (ev.key === 'ArrowUp' && suggestions.length) {
ev.preventDefault();
setArrowed(true);
setHighlight((safeHighlight - 1 + suggestions.length) % suggestions.length);
return;
}
// Space and Enter advance. Tab does NOT accept — tab is navigation, and hijacking it to mean
// "accept" makes it impossible to skip a box you meant to come back to.
//
// A suggestion is only substituted for what you typed when the choice is UNAMBIGUOUS: you arrowed to
// it deliberately, or it is the single remaining match, or you already typed the whole word. Typing
// "fa" and hitting space must NOT silently store "fabric" — for a seed phrase, a wrong-but-valid word
// is the worst possible outcome, because it passes every check this component can do and only fails
// later as an opaque checksum error with no clue which box is at fault. Ambiguous input is left
// exactly as typed, where the missing checkmark makes it obvious.
if (ev.key === ' ' || ev.key === 'Enter') {
if (!current) return;
ev.preventDefault();
const unambiguous = arrowed || suggestions.length === 1 || VALID.has(current);
commit(index, unambiguous ? (suggestions[safeHighlight] ?? current) : current);
return;
}
// Backspace out of an empty box steps back, so correcting a run of words never needs the mouse.
if (ev.key === 'Backspace' && !current && index > 0) {
ev.preventDefault();
focus(index - 1);
}
};
return (
<div className={cn('grid gap-2', WORDS_PER_ROW_HINT)}>
{words.map((word, index) => {
const valid = VALID.has(word);
const invalid = word.length > 0 && !valid;
// Shown whenever the box has focus and anything matches — NOT gated on the word being invalid.
// "fan" is both a complete word and a prefix of "fancy"/"fantasy"; hiding the list the moment the
// text happens to be valid strands the owner mid-word with no way to see the longer options.
const showSuggestions = focused === index && suggestions.length > 0;
return (
<div key={index} className="relative">
<div
className={cn(
'flex h-10 items-center rounded-md border bg-background pl-2 focus-within:ring-2 focus-within:ring-ring',
invalid ? 'border-destructive' : 'border-input',
)}
>
<span className="w-5 shrink-0 select-none text-right font-mono text-[11px] text-muted-foreground">
{index + 1}
</span>
<input
ref={(el) => {
refs.current[index] = el;
}}
value={word}
disabled={disabled}
onChange={(ev) => {
setWord(index, ev.target.value.trim().toLowerCase());
// Typing invalidates a deliberate pick — the list underneath just changed.
setHighlight(0);
setArrowed(false);
}}
onFocus={() => {
setFocused(index);
setHighlight(0);
setArrowed(false);
}}
onBlur={() => setFocused((f) => (f === index ? null : f))}
onPaste={(ev) => handlePaste(index, ev)}
onKeyDown={(ev) => handleKeyDown(index, ev)}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
data-bwignore
className="h-full w-full bg-transparent px-2 font-mono text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
{/* Positive confirmation, not just the absence of red: a typo that happens to be another
real word is caught by reading the phrase back, but a typo that is NOT a word should be
obvious without having to compare against the dropdown. */}
{valid && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</div>
{showSuggestions && (
// onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
<ul className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-input bg-popover shadow-md">
{suggestions.map((s, i) => (
<li key={s}>
<button
type="button"
tabIndex={-1}
// Keeps the highlighted row visible while arrowing through a long list.
ref={i === safeHighlight ? highlightRef : undefined}
onMouseDown={(ev) => {
ev.preventDefault();
commit(index, s);
}}
onMouseEnter={() => setHighlight(i)}
className={cn(
'flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left font-mono text-sm',
i === safeHighlight ? 'bg-accent text-accent-foreground' : 'text-foreground',
)}
>
<span>{s}</span>
{/* The exact match, called out — this is the "yes, that word really exists" signal
when the typed word is also a prefix of longer ones (fan / fancy / fantasy). */}
{s === word && <Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</button>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
);
};