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:
@@ -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