diff --git a/src/servers/sidecar/wallet/aezeed/aez.ts b/src/servers/sidecar/wallet/aezeed/aez.ts new file mode 100644 index 00000000..85105fda --- /dev/null +++ b/src/servers/sidecar/wallet/aezeed/aez.ts @@ -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); +} diff --git a/src/servers/sidecar/wallet/aezeed/aezeed.test.ts b/src/servers/sidecar/wallet/aezeed/aezeed.test.ts new file mode 100644 index 00000000..e27976a7 --- /dev/null +++ b/src/servers/sidecar/wallet/aezeed/aezeed.test.ts @@ -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'); + }); +}); diff --git a/src/servers/sidecar/wallet/aezeed/cipher-seed.ts b/src/servers/sidecar/wallet/aezeed/cipher-seed.ts new file mode 100644 index 00000000..ccca58a3 --- /dev/null +++ b/src/servers/sidecar/wallet/aezeed/cipher-seed.ts @@ -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; + +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(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 { + 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 { + const bytes = mnemonicToCipherSeedBytes(words); + if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH'); + return decipherCipherSeed(bytes, passphrase); +} diff --git a/src/servers/sidecar/wallet/keys.test.ts b/src/servers/sidecar/wallet/keys.test.ts index a39a2014..b122a118 100644 --- a/src/servers/sidecar/wallet/keys.test.ts +++ b/src/servers/sidecar/wallet/keys.test.ts @@ -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', diff --git a/src/servers/sidecar/wallet/keys.ts b/src/servers/sidecar/wallet/keys.ts index 69a04d1f..cae80d3d 100644 --- a/src/servers/sidecar/wallet/keys.ts +++ b/src/servers/sidecar/wallet/keys.ts @@ -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 { } /** - * 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 { + 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 { 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 { - 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 { + 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 }> { 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 = ''; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx index 86a9fedd..33853868 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx @@ -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('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(24); + const [importWords, setImportWords] = useState(() => 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].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 ( <> (next ? onOpenChange(true) : close())}> - + {/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */} + { + if (hasTypedSecret) ev.preventDefault(); + }} + onEscapeKeyDown={(ev) => { + if (hasTypedSecret) ev.preventDefault(); + }} + >
Add a wallet @@ -215,19 +301,116 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet {importing ? (
- +
+ + toggleTextMode(!asText)} + label={asText ? 'Use word boxes' : 'Paste as text'} + /> +
+ + {!asText && ( +
+ {SEED_LENGTHS.map((n) => ( + resizeImport(n)} + label={`${n}`} + /> + ))} +
+ )} + {/* Secret in flight: state → request body → cleared. Never stored on this side, and never echoed back by the sidecar either. */} -