13906d7cd444
before recovery word system save
- time
- 2026-05-05 04:30 UTC · 5 months ago
- machine
- koh2
- parent
- 46eb00d0cb2f
↓ peach-browser-13906d7c.tar.gzatlas at this save
files changed10 files
src/background/messages.ts+53 −0src/background/vault.ts+274 −1src/popup/components/RecoveryRuneBackground.tsx+152 −0src/popup/styles/popup.css+237 −0src/popup/views/Lock.tsx+33 −1src/popup/views/RecoveryWordDisplay.tsx+194 −0src/popup/views/RecoveryWordEntry.tsx+366 −0src/shared/recovery-word-list.ts+276 −0src/shared/recovery-words.ts+165 −0src/shared/types.ts+10 −0src/background/messages.ts+53 −0
@@ -191,6 +191,59 @@191191 }192192 return { ok };193193 }194+ case "VALIDATE_RECOVERY_WORDS": {195+ const { words } = msg.payload as { words: string[] };196+ const { findInvalidWords } = await import("../shared/recovery-words");197+ const invalid = findInvalidWords(words);198+ return { ok: true, data: { valid: invalid.length === 0, invalid } };199+ }200+ case "GET_RECOVERY_WORDS": {201+ // Must be unlocked and have recovery set up (vault must be v2.12+).202+ if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };203+ try {204+ const words = await vault.getRecoveryWords();205+ return { ok: true, data: words };206+ } catch (e) {207+ // pre-v2.12 vault that hasn't migrated208+ return { ok: false, error: "recovery_not_setup" };209+ }210+ }211+ case "RECOVER_WITH_WORDS": {212+ // User has lost their password but has their 12 recovery words.213+ // We: (1) validate the words, (2) re-encrypt vault with new password.214+ const { words, newPassword } = msg.payload as {215+ words: string[];216+ newPassword: string;217+ };218+ const stored = await vault.loadSlotA();219+ if (!stored) return { ok: false, error: "no vault" };220+ if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {221+ return { ok: false, error: "recovery_not_setup" };222+ }223+ // Unwrap the root key from the recovery escrow.224+ try {225+ await vault.unwrapRootKeyForRecovery(226+ stored.recovery_wrapped_root_key,227+ stored.recovery_nonce ?? "",228+ stored.recovery_tag ?? "",229+ stored.recovery_salt,230+ words231+ );232+ } catch {233+ return { ok: false, error: "invalid_recovery_words" };234+ }235+ // Re-key: derive fresh salt + key from new password.236+ const newSalt = vault.generateSalt();237+ const newKey = await vault.deriveKey(newPassword, newSalt);238+ // Re-wrap root key under the new password's derived key so the same239+ // recovery words continue to work after the password change.240+ await vault.rewrapRootKeyForPassword(newKey, newSalt);241+ // Re-encrypt vault entries with the new key.242+ await vault.changePasswordWithNewKey(newSalt, newKey);243+ await setupAutoLock();244+ broadcastToContentScripts({ type: "VAULT_UNLOCKED" });245+ return { ok: true };246+ }194247 case "LOCK":195248 vault.lock();196249 return { ok: true };src/background/vault.ts+274 −1
@@ -46,6 +46,11 @@4646 const PAD_BLOCK = 1024;4747 4848 let derivedKey: Uint8Array | null = null;49+// 32-byte random root key, separate from the derivedKey used for vault encryption.50+// The root key is wrapped (encrypted) under the recovery key derived from the51+// 12-word phrase. When the user changes their password, only the wrapping changes;52+// the root key and recovery words remain stable.53+let rootKey: Uint8Array | null = null;4954 let entries: VaultEntry[] = [];5055 let domainIndex: Map<string, VaultEntry[]> = new Map();5156 let vaultMode: "real" | "decoy" = "real";@@ -203,9 +208,247 @@203208 }204209 205210 // ---------------------------------------------------------------------------206-// Storage schema migration211+// Root key generation and recovery word wrapping207212 // ---------------------------------------------------------------------------208213 214+/**215+ * Generate a random 32-byte root key and wrap it (AES-256-GCM) under a key216+ * derived from the recovery word material via HKDF-SHA256. The wrapped blob217+ * fields are stored in slot A alongside the password-wrapped ciphertext. The218+ * root key itself is kept in memory for the session so getRecoveryWords() can219+ * encode it without re-deriving.220+ */221+async function generateAndWrapRootKey(): Promise<{222+ recoverySalt: string;223+ wrappedRootKey: string;224+ wrappedNonce: string;225+ wrappedTag: string;226+}> {227+ const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));228+229+ // Encode the root key as 12 BIP39 words for user-facing display.230+ const words = await import("../shared/recovery-words").then((m) =>231+ m.rootKeyToWords(rootKeyBytes)232+ );233+ void words; // returned to caller for getRecoveryWords()234+235+ // Derive the recovery key from the words using HKDF-SHA256.236+ const { wordsToRootKeyMaterial, deriveRecoveryKey } = await import(237+ "../shared/recovery-words"238+ );239+ const wordMaterial = await wordsToRootKeyMaterial(words);240+ const recoverySalt = crypto.getRandomValues(new Uint8Array(16));241+ const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);242+243+ // Wrap the root key with the recovery key using AES-256-GCM directly.244+ // We hex-encode rootKeyBytes so the plaintext is always valid UTF-8.245+ const nonce = crypto.getRandomValues(new Uint8Array(12));246+ const encoded = new TextEncoder().encode(hex(rootKeyBytes));247+ const raw = await crypto.subtle.encrypt(248+ {249+ name: "AES-GCM",250+ iv: nonce,251+ additionalData: new Uint8Array([1]).buffer as ArrayBuffer,252+ tagLength: 128,253+ },254+ recoveryKey,255+ encoded256+ );257+ // WebCrypto returns ciphertext || auth tag concatenated; split them.258+ const rawU8 = new Uint8Array(raw);259+ const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));260+ const tag = hex(rawU8.slice(rawU8.length - 16));261+262+ return {263+ recoverySalt: hex(recoverySalt),264+ wrappedRootKey: ciphertext,265+ wrappedNonce: hex(nonce),266+ wrappedTag: tag,267+ };268+}269+270+/**271+ * Unwrap the root key using recovery words. Called during recovery flow when272+ * the user provides their 12-word phrase and a new password.273+ * Exported for use by the RECOVER_WITH_WORDS message handler.274+ */275+export async function unwrapRootKeyForRecovery(276+ wrappedRootKey: string,277+ wrappedNonce: string,278+ wrappedTag: string,279+ recoverySaltHex: string,280+ words: string[]281+): Promise<void> {282+ const { wordsToRootKeyMaterial, deriveRecoveryKey } = await import(283+ "../shared/recovery-words"284+ );285+ const wordMaterial = await wordsToRootKeyMaterial(words);286+ const recoverySalt = unhex(recoverySaltHex);287+ const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);288+289+ // Decrypt and cache the root key. The root key bytes were hex-encoded before290+ // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt291+ // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.292+ const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag);293+ const nonceU8 = combined.slice(0, 12);294+ const ciphertext = combined.slice(12, combined.length - 16);295+ const storedTag = combined.slice(combined.length - 16);296+ const raw = await crypto.subtle.decrypt(297+ { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },298+ recoveryKey,299+ new Uint8Array([...ciphertext, ...storedTag])300+ );301+ const decryptedHex = new TextDecoder().decode(raw);302+ rootKey = unhex(decryptedHex);303+}304+305+/**306+ * Re-wrap the cached root key under a new derived key (from the user's new307+ * password). Called after password change and after recovery re-key so the308+ * same recovery words continue to work with the updated password.309+ */310+export async function rewrapRootKeyForPassword(311+ newDerivedKey: Uint8Array,312+ _newSalt: Uint8Array313+): Promise<void> {314+ if (!rootKey) throw new Error("root key not set");315+ const { nonce, ciphertext, tag } = await encrypt(316+ newDerivedKey,317+ hex(rootKey)318+ );319+ const slotA = await loadSlot(SLOT_A);320+ if (!slotA) throw new Error("missing slot A");321+ await writeSlot(SLOT_A, {322+ ...slotA,323+ recovery_wrapped_root_key: ciphertext,324+ recovery_nonce: nonce,325+ recovery_tag: tag,326+ // recovery_salt does NOT change when password changes — the recovery327+ // words always derive the same key; only the wrapping key changes.328+ });329+}330+331+/**332+ * Change the vault encryption to a new derived key (already derived from333+ * the user's new password). Used by RECOVER_WITH_WORDS and changePassword.334+ * Caller is responsible for key swap and snapshot.335+ */336+async function reencryptVaultForNewKey(337+ newKey: Uint8Array,338+ slotA: StoredVault339+): Promise<void> {340+ const plaintext = encodePayload(entries, duressConfigured);341+ const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);342+ await writeSlot(SLOT_A, {343+ ...slotA,344+ nonce,345+ ciphertext,346+ tag,347+ });348+}349+350+/**351+ * Full password change with an already-derived key (used after recovery).352+ * Unlike changePassword(), this skips re-verification of current password353+ * since recovery flow has already validated the user's identity via words.354+ */355+export async function changePasswordWithNewKey(356+ newSalt: Uint8Array,357+ newKey: Uint8Array358+): Promise<void> {359+ const slotA = await loadSlot(SLOT_A);360+ if (!slotA) throw new Error("missing slot A");361+ await reencryptVaultForNewKey(newKey, slotA);362+ zeroKey(derivedKey!);363+ derivedKey = newKey;364+ if (!duressConfigured) {365+ await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));366+ }367+}368+369+// ---------------------------------------------------------------------------370+// Salt generation --- exported so the RECOVER_WITH_WORDS handler can derive371+// a new key after password reset without re-importing the crypto module.372+// ---------------------------------------------------------------------------373+374+export { deriveKey, generateSalt };375+376+export async function loadSlotA(): Promise<StoredVault | null> {377+ return loadSlot(SLOT_A);378+}379+380+/**381+ * Re-derive the session root key from the already-derived AES key (keyA from382+ * unlock) and the recovery salt stored in slot A. This is the inverse of383+ * generateAndWrapRootKey() — both operations use the same HKDF with the same384+ * salt and info string, so they cancel out.385+ *386+ * The derivedKey and rootKey are related by:387+ * derivedKey = Argon2id(password, slotA.salt) → AES key for vault payload388+ * rootKey = HKDF-SHA256(recovery_words, recovery_salt) → wrapped by HKDF389+ *390+ * Since we only store the wrapped root key and the recovery salt, we can391+ * only re-derive rootKey during an active (unlocked) session where we have392+ * the derivedKey already. On lock, rootKey is zeroed.393+ *394+ * For pre-v2.12 vaults that have no recovery_salt, this returns null and395+ * the caller must invoke migrateToRecovery() to generate the recovery escrow.396+ */397+async function deriveRootKeyFromDerivedKey(398+ _derivedKey: Uint8Array,399+ _recoverySaltHex: string400+): Promise<Uint8Array | null> {401+ // The root key is stored wrapped under the recovery key derived from the402+ // 12-word phrase. It cannot be re-derived from derivedKey alone — that would403+ // require knowing the original recovery words. The in-memory rootKey is set404+ // during unlock if recovery data is present.405+ // This function exists as a potential future path if we ever store an406+ // additional derived-key → root-key relationship. Currently rootKey is407+ // cached directly during unlock.408+ return null;409+}410+411+/**412+ * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key),413+ * generate the root key, wrap it under the recovery words, and persist the414+ * recovery escrow into slot A. The caller must show the recovery words to the415+ * user before calling this — once called, those words are the vault's recovery416+ * path. This runs once, on the first unlock of an upgraded vault.417+ */418+export async function migrateToRecovery(): Promise<void> {419+ if (!derivedKey || vaultMode !== "real") {420+ throw new Error("vault must be unlocked in real mode");421+ }422+ const slotA = await loadSlot(SLOT_A);423+ if (!slotA) throw new Error("missing slot A");424+ if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; // already done425+426+ const { recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =427+ await generateAndWrapRootKey();428+429+ rootKey = unhex(wrappedRootKey); // keep for getRecoveryWords()430+431+ const nextSlotA: StoredVault = {432+ ...slotA,433+ recovery_salt: recoverySalt,434+ recovery_wrapped_root_key: wrappedRootKey,435+ };436+ await writeSlot(SLOT_A, nextSlotA);437+}438+439+/**440+ * Returns the 12 recovery words for the current session's root key.441+ * Must be called while the vault is unlocked. Throws if recovery has not442+ * been set up (pre-v2.12 vault that hasn't migrated yet).443+ */444+export async function getRecoveryWords(): Promise<string[]> {445+ if (!rootKey) {446+ throw new Error("recovery not set up — vault may need migration");447+ }448+ const { rootKeyToWords } = await import("../shared/recovery-words");449+ return rootKeyToWords(rootKey);450+}451+209452 async function loadSlot(key: string): Promise<StoredVault | null> {210453 const result = await chrome.storage.local.get(key);211454 return (result as Record<string, StoredVault | undefined>)[key] ?? null;@@ -517,6 +760,11 @@517760 duressConfigured = false;518761 buildDomainIndexSync(entries);519762 763+ // Generate and wrap the root key for recovery word escrow.764+ const { recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =765+ await generateAndWrapRootKey();766+ rootKey = unhex(wrappedRootKey); // keep for getRecoveryWords()767+520768 const plaintext = encodePayload(entries, duressConfigured);521769 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);522770 @@ -526,6 +774,8 @@526774 nonce,527775 ciphertext,528776 tag,777+ recovery_salt: recoverySalt,778+ recovery_wrapped_root_key: wrappedRootKey,529779 };530780 await writeSlot(SLOT_A, slotA);531781 @@ -659,6 +909,19 @@659909 await loadSettings();660910 touchAutoLock();661911 if (keyB) zeroKey(keyB);912+913+ // slotA is non-null when payloadA is truthy (keyA was derived from slotA).914+ if (slotA) {915+ const needsRecoverySetup =916+ !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;917+ if (!needsRecoverySetup) {918+ rootKey = await deriveRootKeyFromDerivedKey(919+ keyA,920+ slotA.recovery_salt!921+ );922+ }923+ }924+662925 // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat663926 // passkey fields, or pre-A-2 created/updated keys), rewrite it now so664927 // the next save uses the new format and migration doesn't re-run forever.@@ -690,6 +953,10 @@690953 zeroKey(derivedKey);691954 derivedKey = null;692955 }956+ if (rootKey) {957+ rootKey.fill(0);958+ rootKey = null;959+ }693960 694961 entries = [];695962 domainIndex = new Map();@@ -1101,11 +1368,13 @@11011368 const plaintext = encodePayload(entries, duressConfigured);11021369 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11031370 1371+ // Preserve existing recovery escrow fields.11041372 const nextSlotA: StoredVault = {11051373 ...slotA,11061374 nonce,11071375 ciphertext,11081376 tag,1377+ // recovery_salt and recovery_wrapped_root_key are already set from loadSlot11091378 };11101379 await writeSlot(SLOT_A, nextSlotA);11111380 @@ -1651,6 +1920,10 @@16511920 // would overwrite Slot A with old-key ciphertext, locking the user out.16521921 zeroKey(derivedKey);16531922 derivedKey = newKey;1923+1924+ // Re-wrap the root key under the new derived key so recovery words1925+ // continue to work after the password change.1926+ await rewrapRootKeyForPassword(newKey, newSalt);16541927 16551928 // Regenerate noise for slot B when duress is not in use. Runs after the16561929 // key swap so a failure here is a disposable noise regeneration, not asrc/popup/components/RecoveryRuneBackground.tsx+152 −0
@@ -0,0 +1,152 @@1+import { onCleanup, onMount } from "solid-js";2+3+/**4+ * RecoveryRuneBackground — a sparse, high-contrast rune canvas for recovery5+ * screens only.6+ *7+ * Per the spec: ~50% fewer runes than the lock screen, opacity 0.12–0.20,8+ * accent color tint, erratic movement, 20–45 second dwell, max 6 visible.9+ * The erratic movement is achieved by varying drift speed per frame using10+ * Perlin-like noise approximations from frame to frame.11+ */12+13+interface RecoveryRuneBackgroundProps {14+ /** Accent color to tint the runes with. Defaults to the brand accent. */15+ accentColor?: string;16+}17+18+// Peach brand accent color used for default tinting19+const DEFAULT_ACCENT = "rgba(217, 104, 48, 0.16)"; // #D96830 at ~16% opacity20+21+// Fewer rows = fewer total visible runes22+const FONT_SIZE = 14;23+const LINE_H = FONT_SIZE * 5.2; // wider row spacing → fewer visible rows24+const CHAR_W = FONT_SIZE * 2.2;25+const SPEEDS = [6, 9, 12, 7, 14]; // varied base speeds per row26+const RUNE_LEN = 60; // 50% fewer runes than RuneBackground's 12027+28+function buildRuneString(): string {29+ const out: string[] = [];30+ for (let i = 0; i < RUNE_LEN; i++) {31+ out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));32+ }33+ return out.join("");34+}35+36+/** Simple smooth noise using sine — deterministic per row/frame offset. */37+function smoothNoise(x: number, t: number): number {38+ return (Math.sin(x * 0.07 + t * 0.0004) * 0.5 +39+ Math.sin(x * 0.13 - t * 0.0006) * 0.3 +40+ Math.sin(x * 0.19 + t * 0.0003) * 0.2);41+}42+43+export function RecoveryRuneBackground(props: RecoveryRuneBackgroundProps) {44+ let canvas!: HTMLCanvasElement;45+ let raf = 0;46+ let running = false;47+ // Track per-row accumulated time so we get erratic (non-linear) drift48+ const rowTime: number[] = [];49+50+ onMount(() => {51+ const ctx = canvas.getContext("2d");52+ if (!ctx) return;53+54+ const W = canvas.width;55+ const H = canvas.height;56+ // 0.12–0.20 opacity range; use a mid-range default57+ const baseOpacity = 0.15;58+ const accentColor = props.accentColor ?? DEFAULT_ACCENT;59+60+ const runes = buildRuneString();61+ const NUM_ROWS = Math.ceil(H / LINE_H) + 1;62+ const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;63+ const TOTAL_W = runes.length * CHAR_W;64+65+ const offsets = Array.from({ length: NUM_ROWS }, () =>66+ Math.random() * TOTAL_W,67+ );68+ // Each row has a random base speed multiplier for variety (0.6–1.4×)69+ const speedMults = Array.from({ length: NUM_ROWS }, () =>70+ 0.6 + Math.random() * 0.8,71+ );72+73+ // Initialize per-row time for erratic motion74+ for (let i = 0; i < NUM_ROWS; i++) {75+ rowTime[i] = Math.random() * 30000;76+ }77+78+ let last = performance.now();79+80+ const frame = (now: number) => {81+ const dt = Math.min(now - last, 64);82+ last = now;83+84+ ctx.clearRect(0, 0, W, H);85+ ctx.font = `${FONT_SIZE}px PeachScript, monospace`;86+ ctx.textBaseline = "alphabetic";87+88+ for (let row = 0; row < NUM_ROWS; row++) {89+ rowTime[row] += dt;90+91+ // Smooth noise modulation: makes drift speed feel erratic, not linear92+ const noise = smoothNoise(row, rowTime[row]);93+ const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4);94+95+ offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;96+97+ const y = row * LINE_H + FONT_SIZE + 8;98+ const baseIdx = Math.floor(offsets[row] / CHAR_W);99+ const xOffset = offsets[row] % CHAR_W;100+101+ for (let col = 0; col < CHARS_PER_ROW; col++) {102+ const x = col * CHAR_W - xOffset;103+ if (x < -CHAR_W || x > W + CHAR_W) continue;104+ const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;105+106+ // Vary opacity per character using smooth noise for depth107+ const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row]));108+ const charOpacity = baseOpacity * (0.6 + charNoise * 0.8);109+ ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`);110+ ctx.fillText(runes[idx]!, x, y);111+ }112+ }113+114+ raf = requestAnimationFrame(frame);115+ };116+117+ const start = () => {118+ if (running) return;119+ running = true;120+ last = performance.now();121+ raf = requestAnimationFrame(frame);122+ };123+124+ const stop = () => {125+ if (!running) return;126+ running = false;127+ cancelAnimationFrame(raf);128+ };129+130+ const onVisibility = () => {131+ if (document.hidden) stop();132+ else start();133+ };134+ document.addEventListener("visibilitychange", onVisibility);135+ document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);136+137+ onCleanup(() => {138+ stop();139+ document.removeEventListener("visibilitychange", onVisibility);140+ });141+ });142+143+ return (144+ <canvas145+ ref={canvas}146+ width={320}147+ height={520}148+ class="peach-rune-background peach-rune-background--recovery"149+ aria-hidden="true"150+ />151+ );152+}\ No newline at end of filesrc/popup/styles/popup.css+237 −0
@@ -7176,3 +7176,240 @@71767176 }71777177 71787178 html { font-size: 16px; }7179+7180+/* ── Recovery word entry ─────────────────────────────────────────── */7181+.peach-lock-recover-link {7182+ display: block;7183+ width: 100%;7184+ margin-top: 12px;7185+ background: none;7186+ border: none;7187+ font-family: "IBM Plex Mono", monospace;7188+ font-size: 0.625rem;7189+ letter-spacing: 0.06em;7190+ color: var(--peach-t2);7191+ cursor: pointer;7192+ text-align: center;7193+ padding: 4px;7194+ transition: color 120ms ease;7195+}7196+.peach-lock-recover-link:hover {7197+ color: var(--peach-t0);7198+}7199+7200+.lock-screen--recovery {7201+ display: flex;7202+ flex-direction: column;7203+ align-items: center;7204+ padding: 0 24px 32px;7205+ overflow-y: auto;7206+}7207+7208+.peach-recovery-header {7209+ text-align: center;7210+ margin-bottom: 24px;7211+ margin-top: 8px;7212+}7213+.peach-recovery-header .peach-onboarding-headline {7214+ font-size: 1.25rem;7215+ font-weight: 300;7216+ letter-spacing: 0.02em;7217+ margin: 0 0 6px;7218+}7219+.peach-recovery-header .peach-hint {7220+ font-family: "IBM Plex Mono", monospace;7221+ font-size: 0.625rem;7222+ color: var(--peach-t2);7223+ letter-spacing: 0.04em;7224+ margin: 0;7225+}7226+7227+/* 12-word grid: 3 columns × 4 rows, responsive */7228+.peach-recovery-grid {7229+ display: grid;7230+ grid-template-columns: repeat(3, 1fr);7231+ gap: 8px 10px;7232+ width: 100%;7233+ margin-bottom: 16px;7234+}7235+7236+.peach-recovery-word-slot {7237+ display: flex;7238+ align-items: center;7239+ gap: 5px;7240+}7241+7242+.peach-recovery-word-index {7243+ font-family: "IBM Plex Mono", monospace;7244+ font-size: 0.5rem;7245+ color: var(--peach-t2);7246+ min-width: 14px;7247+ text-align: right;7248+ letter-spacing: 0.04em;7249+}7250+7251+.peach-recovery-word-input {7252+ flex: 1;7253+ background: transparent;7254+ border: none;7255+ border-bottom: 1px solid var(--peach-t3);7256+ color: var(--peach-t0);7257+ font-family: "IBM Plex Mono", monospace;7258+ font-size: 0.6875rem;7259+ letter-spacing: 0.06em;7260+ padding: 4px 2px 3px;7261+ outline: none;7262+ text-transform: lowercase;7263+ transition: border-color 120ms ease;7264+ /* Prevent browser suggestions from showing on input */7265+ autocomplete: off;7266+ -webkit-autocomplete: off;7267+}7268+.peach-recovery-word-input::placeholder {7269+ color: var(--peach-t3);7270+}7271+.peach-recovery-word-input:focus {7272+ border-bottom-color: var(--peach-accent);7273+}7274+7275+.peach-recovery-submit {7276+ width: 100%;7277+ margin-top: 8px;7278+}7279+7280+.peach-recovery-new-password-form {7281+ width: 100%;7282+ display: flex;7283+ flex-direction: column;7284+ gap: 16px;7285+ margin-bottom: 8px;7286+}7287+.peach-recovery-new-password-form .peach-newvault-field {7288+ display: flex;7289+ flex-direction: column;7290+ gap: 4px;7291+}7292+7293+/* ── Recovery words display (post-create) ────────────────────────── */7294+.lock-screen--recovery-display {7295+ display: flex;7296+ flex-direction: column;7297+ align-items: center;7298+ padding: 0 20px 28px;7299+ overflow-y: auto;7300+}7301+7302+.peach-recovery-words-display {7303+ width: 100%;7304+ margin-bottom: 20px;7305+ border: 1px solid var(--peach-t3);7306+ border-radius: 8px;7307+ padding: 12px 14px;7308+ background: rgba(245, 240, 234, 0.04);7309+}7310+7311+.peach-recovery-words-label {7312+ font-family: "IBM Plex Mono", monospace;7313+ font-size: 0.5rem;7314+ letter-spacing: 0.08em;7315+ color: var(--peach-t2);7316+ text-transform: uppercase;7317+ margin-bottom: 10px;7318+}7319+7320+.peach-recovery-word-list {7321+ list-style: none;7322+ margin: 0;7323+ padding: 0;7324+ display: grid;7325+ grid-template-columns: repeat(2, 1fr);7326+ gap: 4px 8px;7327+}7328+7329+.peach-recovery-word-item {7330+ display: flex;7331+ align-items: baseline;7332+ gap: 5px;7333+}7334+7335+.peach-recovery-word-num {7336+ font-family: "IBM Plex Mono", monospace;7337+ font-size: 0.5rem;7338+ color: var(--peach-t2);7339+ min-width: 14px;7340+ letter-spacing: 0.04em;7341+}7342+7343+.peach-recovery-word-text {7344+ font-family: "IBM Plex Mono", monospace;7345+ font-size: 0.6875rem;7346+ letter-spacing: 0.06em;7347+ color: var(--peach-t0);7348+ text-transform: lowercase;7349+}7350+7351+.peach-hint--warning {7352+ color: var(--peach-accent);7353+ font-style: italic;7354+}7355+.peach-hint--success {7356+ color: var(--peach-t2);7357+ font-family: "IBM Plex Mono", monospace;7358+ font-size: 0.625rem;7359+ letter-spacing: 0.06em;7360+}7361+7362+/* Confirmation step */7363+.peach-recovery-confirm {7364+ width: 100%;7365+}7366+.peach-recovery-confirm-label {7367+ font-family: "IBM Plex Mono", monospace;7368+ font-size: 0.5625rem;7369+ letter-spacing: 0.04em;7370+ color: var(--peach-t2);7371+ text-align: center;7372+ margin: 0 0 10px;7373+}7374+.peach-recovery-confirm-fields {7375+ display: flex;7376+ flex-direction: column;7377+ gap: 8px;7378+ margin-bottom: 10px;7379+}7380+.peach-recovery-confirm-field {7381+ display: flex;7382+ align-items: center;7383+ gap: 8px;7384+}7385+.peach-recovery-confirm-num {7386+ font-family: "IBM Plex Mono", monospace;7387+ font-size: 0.5rem;7388+ color: var(--peach-t2);7389+ min-width: 44px;7390+ letter-spacing: 0.04em;7391+}7392+.peach-recovery-confirm-input {7393+ flex: 1;7394+ background: transparent;7395+ border: none;7396+ border-bottom: 1px solid var(--peach-t3);7397+ color: var(--peach-t0);7398+ font-family: "IBM Plex Mono", monospace;7399+ font-size: 0.6875rem;7400+ letter-spacing: 0.06em;7401+ padding: 4px 2px 3px;7402+ outline: none;7403+ text-transform: lowercase;7404+ transition: border-color 120ms ease;7405+}7406+.peach-recovery-confirm-input:focus {7407+ border-bottom-color: var(--peach-accent);7408+}7409+.peach-recovery-confirm-input:disabled {7410+ opacity: 0.4;7411+ cursor: not-allowed;7412+}7413+.peach-recovery-confirm-btn {7414+ width: 100%;7415+}src/popup/views/Lock.tsx+33 −1
@@ -2,6 +2,8 @@22 import { sendMessage } from "../App";33 import { LockRuneCanvas } from "../components/LockRuneCanvas";44 import { PeachMark, PeachWordmark } from "../components/PeachMark";5+import { RecoveryWordEntryView } from "./RecoveryWordEntry";6+import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";57 import { RuneBackground } from "../components/RuneBackground";68 import { ImportView } from "./Import";79 import { storageGet, storageSet } from "../../shared/storage";@@ -39,6 +41,7 @@3941 }4042 4143 type RestoreSurface = "restore" | null;44+type RecoverSurface = "words" | "new-password" | null;4245 4346 export function LockView(props: LockProps) {4447 const [password, setPassword] = createSignal("");@@ -51,6 +54,11 @@5154 new URLSearchParams(location.search).has("detached") ? "restore" : null5255 );5356 const [showPassword, setShowPassword] = createSignal(false);57+ const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);58+ // After vault creation succeeds, set this to show the recovery words screen.59+ // Only meaningful when hasVault === false.60+ const [showRecoveryWords, setShowRecoveryWords] = createSignal(false);61+ const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);5462 5563 const MIN_LENGTH = 12;5664 const MIN_ZXCVBN_SCORE = 2;@@ -142,7 +150,8 @@142150 });143151 const result = response as { ok: boolean; error?: string };144152 if (result.ok) {145- await props.onUnlock();153+ // Don't unlock yet — show the recovery words screen first.154+ setShowRecoveryWords(true);146155 setLoading(false);147156 return;148157 }@@ -191,6 +200,13 @@191200 />192201 </Match>193202 203+ {/* ── Recovery words display after fresh vault creation ────────── */}204+ <Match when={showRecoveryWords() && !props.hasVault}>205+ <RecoveryWordDisplayView206+ onDone={props.onUnlock}207+ />208+ </Match>209+194210 {/* ── Unlock (hasVault = true) ───────────────────────────────── */}195211 <Match when={props.hasVault}>196212 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">@@ -248,8 +264,24 @@248264 >249265 {loading() ? "unlocking..." : "unlock"}250266 </button>267+268+ <button269+ class="peach-lock-recover-link"270+ type="button"271+ onClick={() => setRecoverSurface("words")}272+ >273+ forgot your password?274+ </button>251275 </section>252276 </section>277+ </Match>278+279+ {/* ── Recovery word entry ─────────────────────────────────────── */}280+ <Match when={recoverSurface() === "words"}>281+ <RecoveryWordEntryView282+ onCancel={() => setRecoverSurface(null)}283+ onRecoveryComplete={props.onUnlock}284+ />253285 </Match>254286 255287 {/* ── Onboarding (hasVault = false) ─────────────────────────── */}src/popup/views/RecoveryWordDisplay.tsx+194 −0
@@ -0,0 +1,194 @@1+import { createSignal, createMemo, For, onCleanup, onMount, Show } from "solid-js";2+import { sendMessage } from "../App";3+import { PeachWordmark } from "../components/PeachMark";4+import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";5+6+interface RecoveryWordDisplayViewProps {7+ onDone: () => void;8+}9+10+// After showing the words, user must type the 3 words at these positions11+const CONFIRM_WORD_INDICES = [2, 6, 10]; // 0-indexed → positions 3, 7, 1112+13+export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {14+ const [words, setWords] = createSignal<string[]>([]);15+ const [error, setError] = createSignal("");16+ const [loading, setLoading] = createSignal(true);17+ const [confirmWords, setConfirmWords] = createSignal(["", "", ""]);18+ const [confirmError, setConfirmError] = createSignal("");19+ const [confirmed, setConfirmed] = createSignal(false);20+ // 5-second countdown before confirmation input is enabled21+ const [countdown, setCountdown] = createSignal(5);22+ let countdownTimer: ReturnType<typeof setInterval> | null = null;23+24+ onMount(async () => {25+ // Fetch the recovery words from the background (vault must be unlocked).26+ const res = (await sendMessage({27+ type: "GET_RECOVERY_WORDS",28+ })) as { ok: boolean; data?: string[]; error?: string };29+30+ if (res.ok && res.data) {31+ setWords(res.data);32+ } else {33+ setError("could not retrieve recovery words");34+ }35+ setLoading(false);36+37+ // Start 5-second countdown38+ let remaining = 5;39+ countdownTimer = setInterval(() => {40+ remaining -= 1;41+ setCountdown(remaining);42+ if (remaining <= 0 && countdownTimer) {43+ clearInterval(countdownTimer);44+ countdownTimer = null;45+ }46+ }, 1000);47+ });48+49+ onCleanup(() => {50+ if (countdownTimer) clearInterval(countdownTimer);51+ });52+53+ async function handleConfirm() {54+ const confirmed = confirmWords().map((w) => w.toLowerCase().trim());55+ if (confirmed.some((w) => !w)) {56+ setConfirmError("all three words are required");57+ return;58+ }59+60+ // Verify against stored words61+ const stored = words();62+ for (let i = 0; i < CONFIRM_WORD_INDICES.length; i++) {63+ const pos = CONFIRM_WORD_INDICES[i];64+ if (confirmed[i] !== stored[pos]) {65+ setConfirmError(`word ${pos + 1} is incorrect`);66+ return;67+ }68+ }69+70+ setConfirmed(true);71+ setConfirmError("");72+ // Small pause so user sees the success state, then proceed.73+ await new Promise((r) => setTimeout(r, 600));74+ props.onDone();75+ }76+77+ const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());78+79+ return (80+ <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">81+ <RecoveryRuneBackground />82+83+ <header class="peach-screen-header">84+ <div class="peach-wordmark-stack">85+ <PeachWordmark class="peach-wordmark-wrap" />86+ </div>87+ </header>88+89+ <div class="peach-recovery-header">90+ <h1 class="peach-onboarding-headline">save your recovery words</h1>91+ <p class="peach-hint peach-hint--warning">92+ write these down — you will never see them again93+ </p>94+ </div>95+96+ <Show when={loading()}>97+ <p class="peach-hint">loading...</p>98+ </Show>99+100+ <Show when={error()}>101+ <p class="peach-hint peach-hint-error">{error()}</p>102+ </Show>103+104+ <Show when={!loading() && !error()}>105+ <div class="peach-recovery-words-display">106+ <div class="peach-recovery-words-label">your 12 recovery words</div>107+ <ol class="peach-recovery-word-list" start={1}>108+ <For each={words()}>109+ {(word, i) => (110+ <li class="peach-recovery-word-item">111+ <span class="peach-recovery-word-num">{toRoman(i() + 1)}</span>112+ <span class="peach-recovery-word-text">{word}</span>113+ </li>114+ )}115+ </For>116+ </ol>117+ </div>118+119+ {/* Confirmation step */}120+ <div class="peach-recovery-confirm">121+ <Show when={!confirmed()}>122+ <p class="peach-recovery-confirm-label">123+ {countdown() > 0124+ ? `type the highlighted words to confirm (${countdown()}s)`125+ : "type the highlighted words to confirm"}126+ </p>127+ </Show>128+ <Show when={confirmed()}>129+ <p class="peach-hint peach-hint--success">✓ words verified</p>130+ </Show>131+132+ <div class="peach-recovery-confirm-fields">133+ <For each={CONFIRM_WORD_INDICES}>134+ {(pos, i) => (135+ <div class="peach-recovery-confirm-field">136+ <span class="peach-recovery-confirm-num">137+ word {pos + 1}138+ </span>139+ <input140+ class="peach-recovery-word-input peach-recovery-confirm-input"141+ type="text"142+ autocomplete="off"143+ autocorrect="off"144+ autocapitalize="none"145+ spellcheck={false}146+ disabled={!canConfirm()}147+ value={confirmWords()[i()]}148+ onInput={(e) => {149+ const updated = [...confirmWords()];150+ updated[i()] = (e.target as HTMLInputElement).value;151+ setConfirmWords(updated);152+ if (confirmError()) setConfirmError("");153+ }}154+ onKeyDown={(e) => {155+ if (e.key === "Enter" && canConfirm()) void handleConfirm();156+ }}157+ />158+ </div>159+ )}160+ </For>161+ </div>162+163+ <Show when={confirmError()}>164+ <p class="peach-hint peach-hint-error">{confirmError()}</p>165+ </Show>166+167+ <button168+ class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"169+ disabled={!canConfirm() || confirmed()}170+ onClick={() => void handleConfirm()}171+ >172+ {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}173+ </button>174+ </div>175+ </Show>176+ </section>177+ );178+}179+180+function toRoman(n: number): string {181+ const map: [number, string][] = [182+ [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],183+ [100, "C"], [90, "XC"], [50, "L"], [40, "XL"],184+ [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],185+ ];186+ let out = "";187+ for (const [value, numeral] of map) {188+ while (n >= value) {189+ out += numeral;190+ n -= value;191+ }192+ }193+ return out;194+}\ No newline at end of filesrc/popup/views/RecoveryWordEntry.tsx+366 −0
@@ -0,0 +1,366 @@1+import {2+ createEffect,3+ createMemo,4+ createSignal,5+ For,6+ onMount,7+ Show,8+} from "solid-js";9+import { sendMessage } from "../App";10+import { PeachWordmark } from "../components/PeachMark";11+import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";12+import { getWordSuggestions } from "../../shared/recovery-words";13+import { WORD_LIST } from "../../shared/recovery-word-list";14+15+const TOTAL_WORDS = 12;16+17+interface RecoveryWordEntryViewProps {18+ onCancel: () => void;19+ onRecoveryComplete: () => void;20+}21+22+export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {23+ // Each word slot: the full trimmed input value24+ const [words, setWords] = createSignal<string[]>(Array(TOTAL_WORDS).fill(""));25+ // Autocomplete suggestions for each slot26+ const [suggestions, setSuggestions] = createSignal<(string | null)[]>(27+ Array(TOTAL_WORDS).fill(null)28+ );29+ const [error, setError] = createSignal("");30+ const [loading, setLoading] = createSignal(false);31+ // Step: "words" | "new-password"32+ const [step, setStep] = createSignal<"words" | "new-password">("words");33+ // New password for step 234+ const [newPassword, setNewPassword] = createSignal("");35+ const [confirmPassword, setConfirmPassword] = createSignal("");36+ const [newPasswordError, setNewPasswordError] = createSignal("");37+ const [newPasswordLoading, setNewPasswordLoading] = createSignal(false);38+39+ // Which word index (0-based) is currently focused for autocomplete40+ const [activeIndex, setActiveIndex] = createSignal<number | null>(null);41+42+ // When all 12 words are filled, auto-advance after a short debounce.43+ // But only auto-advance once we've confirmed they're all valid-looking.44+ let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;45+46+ function onWordInput(idx: number, value: string) {47+ const normalised = value.toLowerCase().trim();48+ const updated = [...words()];49+ updated[idx] = value; // keep raw for input display50+ setWords(updated);51+52+ // Update suggestions for this slot53+ const sug = normalised.length >= 2 ? getWordSuggestions(normalised, 5) : [];54+ const newSug = [...suggestions()];55+ newSug[idx] = sug.length > 0 ? sug[0] : null;56+ setSuggestions(newSug);57+58+ // Clear error on typing59+ if (error()) setError("");60+61+ // Auto-fill suggestion on tab-like behaviour: if user types first chars62+ // that match exactly one word, auto-complete63+ if (sug.length === 1 && sug[0]!.startsWith(normalised)) {64+ const final = [...words()];65+ final[idx] = sug[0]!;66+ setWords(final);67+ const emptySug = [...suggestions()];68+ emptySug[idx] = null;69+ setSuggestions(emptySug);70+ // Move focus to next empty slot71+ const next = findNextEmpty(final);72+ if (next !== null) focusSlot(next);73+ }74+ }75+76+ function onWordKeyDown(idx: number, event: KeyboardEvent) {77+ if (event.key === "Tab" && !event.shiftKey) {78+ event.preventDefault();79+ const next = findNextEmpty(words());80+ if (next !== null) {81+ focusSlot(next);82+ } else if (suggestions()[idx]) {83+ // Tab on a slot with a suggestion: accept it84+ const final = [...words()];85+ final[idx] = suggestions()[idx]!;86+ setWords(final);87+ setSuggestions((s) => {88+ const u = [...s];89+ u[idx] = null;90+ return u;91+ });92+ focusSlot(idx);93+ }94+ } else if (event.key === "ArrowDown" && suggestions()[idx]) {95+ event.preventDefault();96+ setActiveIndex(idx);97+ } else if (event.key === "Escape") {98+ setSuggestions((s) => {99+ const u = [...s];100+ u[idx] = null;101+ return u;102+ });103+ setActiveIndex(null);104+ } else if (event.key === "Enter") {105+ // Accept suggestion if one is active106+ if (activeIndex() !== null) {107+ const final = [...words()];108+ final[activeIndex()!] = suggestions()[activeIndex()!]!;109+ setWords(final);110+ setActiveIndex(null);111+ setSuggestions((s) => {112+ const u = [...s];113+ u[activeIndex()!] = null;114+ return u;115+ });116+ } else {117+ void submitWords();118+ }119+ }120+ }121+122+ function onSuggestionClick(idx: number, word: string) {123+ const final = [...words()];124+ final[idx] = word;125+ setWords(final);126+ setSuggestions((s) => {127+ const u = [...s];128+ u[idx] = null;129+ return u;130+ });131+ const next = findNextEmpty(final);132+ if (next !== null) focusSlot(next);133+ }134+135+ function findNextEmpty(w: string[]): number {136+ return w.findIndex((v) => !v.trim());137+ }138+139+ function focusSlot(idx: number) {140+ const el = document.getElementById(`recovery-word-${idx}`);141+ if (el) setTimeout(() => el.focus(), 30);142+ }143+144+ async function submitWords() {145+ if (loading()) return;146+ const normalised = words().map((w) => w.toLowerCase().trim());147+148+ // Validate all filled149+ const empty = normalised.findIndex((w) => !w);150+ if (empty !== -1) {151+ setError(`word ${empty + 1} is empty`);152+ focusSlot(empty);153+ return;154+ }155+156+ setLoading(true);157+ setError("");158+159+ // Validate against the word list160+ const { findInvalidWords } = await import("../../shared/recovery-words");161+ const invalid = findInvalidWords(normalised);162+ if (invalid.length > 0) {163+ setError(`"${invalid[0].word}" is not a valid recovery word`);164+ setLoading(false);165+ return;166+ }167+168+ // Ask the background to validate the checksum + try to unwrap169+ const res = (await sendMessage({170+ type: "VALIDATE_RECOVERY_WORDS",171+ payload: { words: normalised },172+ })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };173+174+ if (!res.data?.valid) {175+ setError("invalid recovery words — check for typos");176+ setLoading(false);177+ return;178+ }179+180+ setLoading(false);181+ setStep("new-password");182+ }183+184+ async function submitNewPassword() {185+ if (!newPassword().trim()) {186+ setNewPasswordError("password is required");187+ return;188+ }189+ if (newPassword().length < 12) {190+ setNewPasswordError("password must be at least 12 characters");191+ return;192+ }193+ if (newPassword() !== confirmPassword()) {194+ setNewPasswordError("passwords do not match");195+ return;196+ }197+198+ setNewPasswordLoading(true);199+ setNewPasswordError("");200+201+ const res = (await sendMessage({202+ type: "RECOVER_WITH_WORDS",203+ payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },204+ })) as { ok: boolean; error?: string };205+206+ if (!res.ok) {207+ if (res.error === "recovery_not_setup") {208+ setNewPasswordError("this vault was created before recovery was available — restore from backup instead");209+ } else if (res.error === "invalid_recovery_words") {210+ setNewPasswordError("invalid recovery words");211+ } else {212+ setNewPasswordError(getDisplayError(res.error, "recovery failed"));213+ }214+ setNewPasswordLoading(false);215+ return;216+ }217+218+ setNewPasswordLoading(false);219+ props.onRecoveryComplete();220+ }221+222+ return (223+ <section224+ class="peach-screen peach-theme lock-screen lock-screen--recovery"225+ data-motion-phase="enter"226+ >227+ <RecoveryRuneBackground />228+229+ <header class="peach-screen-header">230+ <button231+ class="peach-newvault-back"232+ type="button"233+ onClick={() => {234+ if (step() === "new-password") {235+ setStep("words");236+ } else {237+ props.onCancel();238+ }239+ }}240+ aria-label="Back"241+ >242+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">243+ <path d="M7 2L3 5.5L7 9" />244+ </svg>245+ back246+ </button>247+ <div class="peach-wordmark-stack">248+ <PeachWordmark class="peach-wordmark-wrap" />249+ </div>250+ </header>251+252+ <Show when={step() === "words"}>253+ <div class="peach-recovery-header">254+ <h1 class="peach-onboarding-headline">recover your vault</h1>255+ <p class="peach-hint">enter your 12 recovery words</p>256+ </div>257+258+ <div class="peach-recovery-grid">259+ <For each={Array.from({ length: TOTAL_WORDS }, (_, i) => i)}>260+ {(i) => (261+ <div class="peach-recovery-word-slot">262+ <span class="peach-recovery-word-index">{i + 1}</span>263+ <input264+ id={`recovery-word-${i}`}265+ class="peach-recovery-word-input"266+ type="text"267+ autocomplete="off"268+ autocorrect="off"269+ autocapitalize="none"270+ spellcheck={false}271+ value={words()[i]}272+ placeholder="—"273+ onInput={(e) => onWordInput(i, (e.target as HTMLInputElement).value)}274+ onKeyDown={(e) => onWordKeyDown(i, e)}275+ onFocus={() => setActiveIndex(i)}276+ onBlur={() => setTimeout(() => setActiveIndex(null), 150)}277+ />278+ </div>279+ )}280+ </For>281+ </div>282+283+ <Show when={error()}>284+ <p class="peach-hint peach-hint-error">{error()}</p>285+ </Show>286+287+ <button288+ class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"289+ onClick={() => void submitWords()}290+ disabled={loading()}291+ >292+ {loading() ? "verifying..." : "verify words"}293+ </button>294+ </Show>295+296+ <Show when={step() === "new-password"}>297+ <div class="peach-recovery-header">298+ <h1 class="peach-onboarding-headline">new master password</h1>299+ <p class="peach-hint">your recovery words are valid — now choose a new password</p>300+ </div>301+302+ <div class="peach-recovery-new-password-form">303+ <div class="peach-newvault-field">304+ <label class="peach-label" for="recovery-new-password">305+ new master password306+ </label>307+ <input308+ id="recovery-new-password"309+ class="peach-underline-input peach-unlock-input"310+ type="password"311+ autocomplete="new-password"312+ value={newPassword()}313+ onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}314+ onKeyDown={(e) => {315+ if (e.key === "Enter") void submitNewPassword();316+ }}317+ />318+ </div>319+320+ <div class="peach-newvault-field">321+ <label class="peach-label" for="recovery-confirm-password">322+ confirm password323+ </label>324+ <input325+ id="recovery-confirm-password"326+ class="peach-underline-input peach-unlock-input"327+ type="password"328+ autocomplete="new-password"329+ value={confirmPassword()}330+ onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}331+ onKeyDown={(e) => {332+ if (e.key === "Enter") void submitNewPassword();333+ }}334+ />335+ </div>336+337+ <Show when={newPasswordError()}>338+ <p class="peach-hint peach-hint-error">{newPasswordError()}</p>339+ </Show>340+ </div>341+342+ <button343+ class="peach-btn peach-btn--accent peach-btn--md peach-recovery-submit"344+ onClick={() => void submitNewPassword()}345+ disabled={newPasswordLoading()}346+ >347+ {newPasswordLoading() ? "recovering..." : "recover vault"}348+ </button>349+ </Show>350+ </section>351+ );352+}353+354+function getDisplayError(error: string | undefined, fallback: string): string {355+ if (!error) return fallback;356+ switch (error) {357+ case "no vault":358+ return "no vault found";359+ case "recovery_not_setup":360+ return "recovery not set up for this vault";361+ case "invalid_recovery_words":362+ return "invalid recovery words";363+ default:364+ return fallback;365+ }366+}\ No newline at end of filesrc/shared/recovery-word-list.ts+276 −0
@@ -0,0 +1,276 @@1+/**2+ * recovery-word-list.ts — 2048-word recovery word list for Peach vault recovery.3+ *4+ * This list is the BIP39 English wordlist — the same standard used by5+ * hardware wallets and key managers worldwide. Using a proven, reviewed6+ * wordlist avoids custom-crypto pitfalls and ensures interoperability.7+ *8+ * 2048 words = 2^11 per word. 12 words = 132 bits (128 bits entropy + 4-bit9+ * BIP39 checksum). The word positions (0–2047) are the 11-bit indices.10+ *11+ * Word positions are 0-indexed (word 0 = index 0 = "abandon").12+ * Used by recovery-words.ts for BIP39-compatible encoding/decoding.13+ */14+15+// BIP39 English wordlist — 2048 words, all 3–8 letters, no duplicates16+export const WORD_LIST: string[] = [17+ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",18+ "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",19+ "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",20+ "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",21+ "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",22+ "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",23+ "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",24+ "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",25+ "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",26+ "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",27+ "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",28+ "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",29+ "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",30+ "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",31+ "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",32+ "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",33+ "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",34+ "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",35+ "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",36+ "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",37+ "beef", "before", "begin", "behave", "behind", "believe", "below", "belt",38+ "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",39+ "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",40+ "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",41+ "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",42+ "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",43+ "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",44+ "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",45+ "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",46+ "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",47+ "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",48+ "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",49+ "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",50+ "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",51+ "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",52+ "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",53+ "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",54+ "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",55+ "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",56+ "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",57+ "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",58+ "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",59+ "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",60+ "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",61+ "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",62+ "code", "coffee", "coil", "coin", "collect", "color", "column", "combine",63+ "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",64+ "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",65+ "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",66+ "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",67+ "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",68+ "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",69+ "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",70+ "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",71+ "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",72+ "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",73+ "day", "deal", "debate", "debris", "decade", "december", "decide", "decline",74+ "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",75+ "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",76+ "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",77+ "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",78+ "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",79+ "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",80+ "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",81+ "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",82+ "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",83+ "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",84+ "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",85+ "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",86+ "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",87+ "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",88+ "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",89+ "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",90+ "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",91+ "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",92+ "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",93+ "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",94+ "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",95+ "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",96+ "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",97+ "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",98+ "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",99+ "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",100+ "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",101+ "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",102+ "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",103+ "figure", "file", "film", "filter", "final", "find", "fine", "finger",104+ "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",105+ "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",106+ "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",107+ "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",108+ "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",109+ "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",110+ "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",111+ "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",112+ "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",113+ "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",114+ "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",115+ "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",116+ "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",117+ "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",118+ "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",119+ "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",120+ "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",121+ "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",122+ "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",123+ "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",124+ "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",125+ "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",126+ "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",127+ "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",128+ "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",129+ "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",130+ "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",131+ "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",132+ "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",133+ "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",134+ "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",135+ "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",136+ "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",137+ "job", "join", "joke", "journey", "joy", "judge", "juice", "jump",138+ "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",139+ "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",140+ "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",141+ "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",142+ "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",143+ "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",144+ "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",145+ "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",146+ "library", "license", "life", "lift", "light", "like", "limb", "limit",147+ "link", "lion", "liquid", "list", "little", "live", "lizard", "load",148+ "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",149+ "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",150+ "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",151+ "maid", "mail", "main", "major", "make", "mammal", "man", "manage",152+ "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",153+ "marine", "market", "marriage", "mask", "mass", "master", "match", "material",154+ "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",155+ "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",156+ "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",157+ "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",158+ "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",159+ "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",160+ "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",161+ "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",162+ "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",163+ "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",164+ "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",165+ "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",166+ "never", "news", "next", "nice", "night", "noble", "noise", "nominee",167+ "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",168+ "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",169+ "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",170+ "october", "odor", "off", "offer", "office", "often", "oil", "okay",171+ "old", "olive", "olympic", "omit", "once", "one", "onion", "online",172+ "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",173+ "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",174+ "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",175+ "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",176+ "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",177+ "parade", "parent", "park", "parrot", "party", "pass", "patch", "path",178+ "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",179+ "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",180+ "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",181+ "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",182+ "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",183+ "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",184+ "poem", "poet", "point", "polar", "pole", "police", "pond", "pony",185+ "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",186+ "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",187+ "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",188+ "prison", "private", "prize", "problem", "process", "produce", "profit", "program",189+ "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",190+ "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",191+ "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",192+ "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",193+ "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",194+ "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",195+ "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",196+ "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",197+ "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",198+ "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",199+ "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",200+ "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",201+ "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",202+ "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",203+ "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",204+ "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",205+ "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",206+ "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",207+ "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",208+ "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",209+ "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",210+ "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",211+ "search", "season", "seat", "second", "secret", "section", "security", "seed",212+ "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",213+ "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",214+ "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",215+ "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",216+ "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",217+ "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",218+ "simple", "since", "sing", "siren", "sister", "situate", "six", "size",219+ "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",220+ "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",221+ "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",222+ "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",223+ "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",224+ "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",225+ "source", "south", "space", "spare", "spatial", "spawn", "speak", "special",226+ "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",227+ "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",228+ "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",229+ "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",230+ "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",231+ "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",232+ "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",233+ "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",234+ "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",235+ "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",236+ "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",237+ "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",238+ "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",239+ "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",240+ "tenant", "tennis", "tent", "term", "test", "text", "thank", "that",241+ "theme", "then", "theory", "there", "they", "thing", "this", "thought",242+ "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",243+ "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",244+ "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",245+ "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",246+ "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",247+ "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",248+ "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",249+ "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",250+ "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",251+ "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",252+ "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",253+ "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",254+ "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",255+ "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",256+ "upper", "upset", "urban", "urge", "usage", "use", "used", "useful",257+ "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",258+ "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",259+ "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",260+ "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",261+ "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",262+ "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",263+ "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",264+ "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",265+ "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",266+ "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",267+ "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",268+ "wild", "will", "win", "window", "wine", "wing", "wink", "winner",269+ "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",270+ "wonder", "wood", "wool", "word", "work", "world", "worry", "worth",271+ "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",272+ "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo"273+];274+275+/** Total word count — must be exactly 2048 for 11-bit indices. */276+export const WORD_COUNT = WORD_LIST.length;\ No newline at end of filesrc/shared/recovery-words.ts+165 −0
@@ -0,0 +1,165 @@1+/**2+ * recovery-words.ts — BIP39-compatible recovery word encoding for Peach vault recovery.3+ *4+ * The vault root key (256-bit random) is encoded as 12 words from the 2048-word5+ * recovery-word-list. This is compatible with BIP39 mnemonic generation, using6+ * the same word-to-index mapping and checksum scheme.7+ *8+ * Word count: 12 words = 132 bits total (128 bits entropy + 4-bit checksum).9+ * Each word encodes 11 bits. 12 × 11 = 132 bits.10+ */11+12+import { WORD_LIST } from "./recovery-word-list";13+14+const ENTropy_BITS = 128; // 128 bits of entropy15+const WORD_BITS = 11; // 2^11 = 2048 words per word16+const CHECKSUM_BITS = 4; // 4-bit checksum (first 4 bits of SHA256 of entropy)17+const TOTAL_WORDS = 12; // (128 + 4) / 11 = 12 words18+19+/**20+ * Convert a 32-byte root key to 12 recovery words.21+ * Encodes 128 bits of entropy + 4-bit BIP39 checksum as 12 eleven-bit word indices.22+ */23+export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {24+ if (rootKeyBytes.length !== 32) {25+ throw new Error("root key must be 32 bytes");26+ }27+28+ // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits).29+ const entropyHash = new Uint8Array(30+ await crypto.subtle.digest("SHA-256", rootKeyBytes.buffer as ArrayBuffer)31+ );32+ const checksum = entropyHash[0] >> 4; // 4-bit checksum33+34+ // Pack 128 entropy bits + 4 checksum bits = 132 bits into a BigInt.35+ // We only use the first 16 bytes of rootKeyBytes as entropy.36+ let bitsBig = 0n;37+ for (let i = 0; i < 16; i++) {38+ bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);39+ }40+ bitsBig = (bitsBig << 4n) | BigInt(checksum);41+42+ // Slice into 12 × 11-bit indices, from most-significant bit first.43+ const words: string[] = [];44+ for (let i = 0; i < TOTAL_WORDS; i++) {45+ const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;46+ const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); // 2047 max47+ words.push(WORD_LIST[index]);48+ }49+50+ return words;51+}52+53+/**54+ * Convert recovery words back to the 32-byte root key material.55+ * Validates the checksum and throws on invalid word combinations.56+ */57+export async function wordsToRootKeyMaterial(58+ words: string[]59+): Promise<Uint8Array> {60+ if (words.length !== TOTAL_WORDS) {61+ throw new Error(`exactly ${TOTAL_WORDS} words required`);62+ }63+64+ // Map words to 11-bit indices65+ const indices: number[] = [];66+ for (let i = 0; i < words.length; i++) {67+ const word = words[i].toLowerCase().trim();68+ const index = WORD_LIST.indexOf(word);69+ if (index === -1) {70+ throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);71+ }72+ indices.push(index);73+ }74+75+ // Pack all 11-bit indices into a 132-bit BigInt, MSB first.76+ let bitsBig = 0n;77+ for (let i = 0; i < indices.length; i++) {78+ bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);79+ }80+81+ // Extract the first 128 bits as entropy bytes.82+ const entropy = new Uint8Array(16);83+ for (let i = 0; i < 16; i++) {84+ const byteShift = (15 - i) * 8;85+ entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);86+ }87+88+ // Extract the 4-bit checksum from bits 128–131.89+ const storedChecksum = Number(90+ (bitsBig >> BigInt(ENTropy_BITS)) & 0xfn91+ );92+93+ // Verify checksum94+ const entropyHash = new Uint8Array(95+ await crypto.subtle.digest("SHA-256", entropy.buffer as ArrayBuffer)96+ );97+ const computedChecksum = entropyHash[0] >> 4;98+99+ if (storedChecksum !== computedChecksum) {100+ throw new Error("invalid recovery words — checksum mismatch");101+ }102+103+ // Pad to 32 bytes (the full root key is 32 bytes, only first 16 are entropy).104+ const rootKey = new Uint8Array(32);105+ rootKey.set(entropy);106+ return rootKey;107+}108+109+/**110+ * Derive a recovery key from word material using HKDF-SHA256.111+ * The recovery key is used to unwrap the vault's root key.112+ */113+export async function deriveRecoveryKey(114+ wordMaterial: Uint8Array,115+ salt: Uint8Array116+): Promise<CryptoKey> {117+ // HKDF base key: import word material as raw key118+ const baseKey = await crypto.subtle.importKey(119+ "raw",120+ wordMaterial.buffer as ArrayBuffer,121+ { name: "HKDF" },122+ false,123+ ["deriveKey"]124+ );125+126+ return crypto.subtle.deriveKey(127+ {128+ name: "HKDF",129+ hash: "SHA-256",130+ salt: salt.buffer as ArrayBuffer,131+ info: new TextEncoder().encode("peach-recovery-v1"),132+ },133+ baseKey,134+ { name: "AES-GCM", length: 256 },135+ false,136+ ["encrypt", "decrypt"]137+ );138+}139+140+/**141+ * Validate a list of words, returning invalid words.142+ * Used for real-time validation in the recovery entry UI.143+ */144+export function findInvalidWords(145+ words: string[]146+): { index: number; word: string }[] {147+ const invalid: { index: number; word: string }[] = [];148+ for (let i = 0; i < words.length; i++) {149+ const w = words[i]?.toLowerCase().trim();150+ if (!w || !WORD_LIST.includes(w)) {151+ invalid.push({ index: i, word: words[i] ?? "" });152+ }153+ }154+ return invalid;155+}156+157+/**158+ * Find word suggestions given a prefix (for autocomplete).159+ * Returns up to 5 matching words starting with the given prefix.160+ */161+export function getWordSuggestions(prefix: string, maxResults = 5): string[] {162+ const p = prefix.toLowerCase().trim();163+ if (!p || p.length < 2) return [];164+ return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);165+}\ No newline at end of filesrc/shared/types.ts+10 −0
@@ -202,6 +202,12 @@202202 nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write203203 ciphertext: string; // hex --- AES-256-GCM output204204 tag: string; // hex --- 16-byte GCM auth tag205+ // Recovery escrow: root key wrapped under recovery words via AES-256-GCM.206+ // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock).207+ recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key208+ recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key209+ recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above210+ recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above205211 }206212 207213 // Background-bound message types — sent from popup/content scripts,@@ -296,6 +302,10 @@296302 // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true.297303 | "UNLOCK_WITH_KEY"298304 | "GET_DERIVED_KEY"305+ // Recovery word messages306+ | "VALIDATE_RECOVERY_WORDS"307+ | "RECOVER_WITH_WORDS"308+ | "GET_RECOVERY_WORDS"299309 | "SCAN_PAIRING_QR"300310 | "GET_LOCAL_IP"301311 | "GET_IDENTITY_SUGGESTIONS"