RecoveryWordDisplay.tsx

8.2 KB · raw

1import { createSignal, createMemo, For, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { PeachWordmark } from "../components/PeachMark";4import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";56interface RecoveryWordDisplayViewProps {7  onDone: () => void;8}910// Three random positions are confirmed each time this screen mounts, so a11// user can't get away with only memorizing a fixed set of slots.12function pickConfirmIndices(): number[] {13  const pool = Array.from({ length: 12 }, (_, i) => i);14  const rand = new Uint32Array(pool.length);15  crypto.getRandomValues(rand);16  for (let i = pool.length - 1; i > 0; i--) {17    const j = (rand[i] ?? 0) % (i + 1);18    const tmp = pool[i]!;19    pool[i] = pool[j]!;20    pool[j] = tmp;21  }22  return pool.slice(0, 3).sort((a, b) => a - b);23}2425export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {26  // Chosen once per mount; the labels and the validation must agree.27  const confirmIndices = pickConfirmIndices();28  const [words, setWords] = createSignal<string[]>([]);29  const [error, setError] = createSignal("");30  const [loading, setLoading] = createSignal(true);31  const [confirmWords, setConfirmWords] = createSignal(["", "", ""]);32  const [confirmError, setConfirmError] = createSignal("");33  const [confirmed, setConfirmed] = createSignal(false);34  // 5-second countdown before confirmation input is enabled35  const [countdown, setCountdown] = createSignal(5);36  let countdownTimer: ReturnType<typeof setInterval> | null = null;3738  onMount(async () => {39    // Fetch the recovery words from the background (vault must be unlocked).40    const res = (await sendMessage({41      type: "GET_RECOVERY_WORDS",42    })) as { ok: boolean; data?: string[]; error?: string };4344    if (res.ok && res.data) {45      setWords(res.data);46    } else {47      setError("could not retrieve recovery words");48    }49    setLoading(false);5051    // Start 5-second countdown52    let remaining = 5;53    countdownTimer = setInterval(() => {54      remaining -= 1;55      setCountdown(remaining);56      if (remaining <= 0 && countdownTimer) {57        clearInterval(countdownTimer);58        countdownTimer = null;59      }60    }, 1000);61  });6263  onCleanup(() => {64    if (countdownTimer) clearInterval(countdownTimer);65  });6667  async function handleConfirm() {68    const confirmed = confirmWords().map((w) => w.toLowerCase().trim());69    if (confirmed.some((w) => !w)) {70      setConfirmError("all three words are required");71      return;72    }7374    // Verify against stored words75    const stored = words();76    for (let i = 0; i < confirmIndices.length; i++) {77      const pos = confirmIndices[i]!;78      if (confirmed[i] !== stored[pos]) {79        setConfirmError(`word ${pos + 1} is incorrect`);80        return;81      }82    }8384    setConfirmed(true);85    setConfirmError("");86    // Clear the recovery-pending flag so lock-on-close / auto-lock resume87    // their normal behavior and future unlocks no longer re-route here.88    // Once the user has confirmed the words, losing the in-memory key is89    // no longer a data-loss event.90    try {91      await chrome.storage.local?.remove("peach_recovery_pending");92    } catch { /* best-effort */ }93    // Small pause so user sees the success state, then proceed.94    await new Promise((r) => setTimeout(r, 600));95    props.onDone();96  }9798  const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());99100  return (101    <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">102      <RecoveryRuneBackground />103104      <header class="peach-screen-header">105        <div class="peach-wordmark-stack">106          <PeachWordmark class="peach-wordmark-wrap" />107        </div>108      </header>109110      <div class="peach-recovery-header">111        <h1 class="peach-onboarding-headline">save your recovery words</h1>112        <p class="peach-hint peach-hint--warning">113          write these down — you can view them again in Settings with your master password114        </p>115      </div>116117      <Show when={loading()}>118        <p class="peach-hint">loading...</p>119      </Show>120121      <Show when={error()}>122        <p class="peach-hint peach-hint-error">{error()}</p>123      </Show>124125      <Show when={!loading() && !error()}>126        <div class="peach-recovery-words-display">127          <div class="peach-recovery-words-label">your 12 recovery words</div>128          <ol class="peach-recovery-word-list" start={1}>129            <For each={words()}>130              {(word, i) => (131                <li class="peach-recovery-word-item">132                  <span class="peach-recovery-word-num">{i() + 1}</span>133                  <span class="peach-recovery-word-text">{word}</span>134                </li>135              )}136            </For>137          </ol>138          <button139            type="button"140            class="peach-recovery-download-btn"141            onClick={() => void saveRecoveryWords(words())}142          >143            download as .txt144          </button>145        </div>146147        {/* Confirmation step */}148        <div class="peach-recovery-confirm">149          <Show when={!confirmed()}>150            <p class="peach-recovery-confirm-label">151              {countdown() > 0152                ? `type the highlighted words to confirm (${countdown()}s)`153                : "type the highlighted words to confirm"}154            </p>155          </Show>156          <Show when={confirmed()}>157            <p class="peach-hint peach-hint--success">✓ words verified</p>158          </Show>159160          <div class="peach-recovery-confirm-fields">161            <For each={confirmIndices}>162              {(pos, i) => (163                <div class="peach-recovery-confirm-field">164                  <span class="peach-recovery-confirm-num">165                    word {pos + 1}166                  </span>167                  <input168                    class="peach-recovery-word-input peach-recovery-confirm-input"169                    type="text"170                    autocomplete="off"171                    autocorrect="off"172                    autocapitalize="none"173                    spellcheck={false}174                    disabled={!canConfirm()}175                    value={confirmWords()[i()]}176                    onInput={(e) => {177                      const updated = [...confirmWords()];178                      updated[i()] = (e.target as HTMLInputElement).value;179                      setConfirmWords(updated);180                      if (confirmError()) setConfirmError("");181                    }}182                    onKeyDown={(e) => {183                      if (e.key === "Enter" && canConfirm()) void handleConfirm();184                    }}185                  />186                </div>187              )}188            </For>189          </div>190191          <Show when={confirmError()}>192            <p class="peach-hint peach-hint-error">{confirmError()}</p>193          </Show>194195          <button196            class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"197            disabled={!canConfirm() || confirmed()}198            onClick={() => void handleConfirm()}199          >200            {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}201          </button>202        </div>203      </Show>204    </section>205  );206}207208function recoveryWordsText(words: string[]): string {209  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");210  return (211    "PEACH RECOVERY WORDS\n" +212    "====================\n\n" +213    "Anyone with these 12 words can recover your vault.\n" +214    "Store this somewhere only you can access.\n\n" +215    numbered + "\n"216  );217}218219// Desktop only — writes via a blob to a user-chosen save location. The native220// path deliberately does NOT use a file download: on Android the data:-URL221// download sink writes to world-readable public Downloads (audit MEDIUM), so222// native offers a clipboard copy (copyRecoveryWords) the user routes instead.223function saveRecoveryWords(words: string[]): void {224  const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });225  const a = document.createElement("a");226  a.download = "peach-recovery-words.txt";227  a.href = URL.createObjectURL(blob);228  setTimeout(() => URL.revokeObjectURL(a.href), 1000);229  document.body.appendChild(a);230  a.click();231  a.remove();232}