13906d7cd444

before recovery word system save

time
2026-05-05 04:30 UTC · 5 months ago
machine
koh2
parent
46eb00d0cb2f
10 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 −0
src/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 };

view file

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 a

view file

src/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 file

view file

src/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+}

view file

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) ─────────────────────────── */}

view file

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 file

view file

src/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 file

view file

src/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 file

view file

src/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 file

view file

src/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"

view file