Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
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-success" />}
|
|
</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-success" />}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|