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