RecoveryWordDisplay.tsx

8.9 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    await refreshWords();4041    // Start 5-second countdown42    let remaining = 5;43    countdownTimer = setInterval(() => {44      remaining -= 1;45      setCountdown(remaining);46      if (remaining <= 0 && countdownTimer) {47        clearInterval(countdownTimer);48        countdownTimer = null;49      }50    }, 1000);51  });5253  async function refreshWords(): Promise<void> {54    // Fetch the recovery words from the background (vault must be unlocked).55    const res = (await sendMessage({56      type: "GET_RECOVERY_WORDS",57    })) as { ok: boolean; data?: string[]; error?: string };5859    if (res.ok && res.data) {60      setWords(res.data);61    } else {62      setError("could not retrieve recovery words");63    }64    setLoading(false);65  }6667  // Screenshot hygiene: blank the words whenever the window is backgrounded,68  // so an OS screenshot of the hidden popup never captures them. They are69  // re-fetched when the user returns (the recovery-pending window still70  // exempts the fetch from password re-auth).71  const handleVisibilityChange = () => {72    if (document.visibilityState === "hidden") {73      setWords([]);74    } else if (!confirmed()) {75      void refreshWords();76    }77  };78  document.addEventListener("visibilitychange", handleVisibilityChange);7980  onCleanup(() => {81    if (countdownTimer) clearInterval(countdownTimer);82    document.removeEventListener("visibilitychange", handleVisibilityChange);83  });8485  async function handleConfirm() {86    const confirmed = confirmWords().map((w) => w.toLowerCase().trim());87    if (confirmed.some((w) => !w)) {88      setConfirmError("all three words are required");89      return;90    }9192    // Verify against stored words93    const stored = words();94    for (let i = 0; i < confirmIndices.length; i++) {95      const pos = confirmIndices[i]!;96      if (confirmed[i] !== stored[pos]) {97        setConfirmError(`word ${pos + 1} is incorrect`);98        return;99      }100    }101102    setConfirmed(true);103    setConfirmError("");104    // Clear the recovery-pending flag so lock-on-close / auto-lock resume105    // their normal behavior and future unlocks no longer re-route here.106    // Once the user has confirmed the words, losing the in-memory key is107    // no longer a data-loss event.108    try {109      await chrome.storage.local?.remove("peach_recovery_pending");110    } catch { /* best-effort */ }111    // Small pause so user sees the success state, then proceed.112    await new Promise((r) => setTimeout(r, 600));113    props.onDone();114  }115116  const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());117118  return (119    <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">120      <RecoveryRuneBackground />121122      <header class="peach-screen-header">123        <div class="peach-wordmark-stack">124          <PeachWordmark class="peach-wordmark-wrap" />125        </div>126      </header>127128      <div class="peach-recovery-header">129        <h1 class="peach-onboarding-headline">save your recovery words</h1>130        <p class="peach-hint peach-hint--warning">131          write these down — you can view them again in Settings with your master password132        </p>133      </div>134135      <Show when={loading()}>136        <p class="peach-hint">loading...</p>137      </Show>138139      <Show when={error()}>140        <p class="peach-hint peach-hint-error">{error()}</p>141      </Show>142143      <Show when={!loading() && !error()}>144        <div class="peach-recovery-words-display">145          <div class="peach-recovery-words-label">your 12 recovery words</div>146          <ol class="peach-recovery-word-list" start={1}>147            <For each={words()}>148              {(word, i) => (149                <li class="peach-recovery-word-item">150                  <span class="peach-recovery-word-num">{i() + 1}</span>151                  <span class="peach-recovery-word-text">{word}</span>152                </li>153              )}154            </For>155          </ol>156          <button157            type="button"158            class="peach-recovery-download-btn"159            onClick={() => void saveRecoveryWords(words())}160          >161            download as .txt162          </button>163        </div>164165        {/* Confirmation step */}166        <div class="peach-recovery-confirm">167          <Show when={!confirmed()}>168            <p class="peach-recovery-confirm-label">169              {countdown() > 0170                ? `type the highlighted words to confirm (${countdown()}s)`171                : "type the highlighted words to confirm"}172            </p>173          </Show>174          <Show when={confirmed()}>175            <p class="peach-hint peach-hint--success">✓ words verified</p>176          </Show>177178          <div class="peach-recovery-confirm-fields">179            <For each={confirmIndices}>180              {(pos, i) => (181                <div class="peach-recovery-confirm-field">182                  <span class="peach-recovery-confirm-num">183                    word {pos + 1}184                  </span>185                  <input186                    class="peach-recovery-word-input peach-recovery-confirm-input"187                    type="text"188                    autocomplete="off"189                    autocorrect="off"190                    autocapitalize="none"191                    spellcheck={false}192                    disabled={!canConfirm()}193                    value={confirmWords()[i()]}194                    onInput={(e) => {195                      const updated = [...confirmWords()];196                      updated[i()] = (e.target as HTMLInputElement).value;197                      setConfirmWords(updated);198                      if (confirmError()) setConfirmError("");199                    }}200                    onKeyDown={(e) => {201                      if (e.key === "Enter" && canConfirm()) void handleConfirm();202                    }}203                  />204                </div>205              )}206            </For>207          </div>208209          <Show when={confirmError()}>210            <p class="peach-hint peach-hint-error">{confirmError()}</p>211          </Show>212213          <button214            class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"215            disabled={!canConfirm() || confirmed()}216            onClick={() => void handleConfirm()}217          >218            {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}219          </button>220        </div>221      </Show>222    </section>223  );224}225226function recoveryWordsText(words: string[]): string {227  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");228  return (229    "PEACH RECOVERY WORDS\n" +230    "====================\n\n" +231    "Anyone with these 12 words can recover your vault.\n" +232    "Store this somewhere only you can access.\n\n" +233    numbered + "\n"234  );235}236237// Desktop only — writes via a blob to a user-chosen save location. The native238// path deliberately does NOT use a file download: on Android the data:-URL239// download sink writes to world-readable public Downloads (audit MEDIUM), so240// native offers a clipboard copy (copyRecoveryWords) the user routes instead.241function saveRecoveryWords(words: string[]): void {242  const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });243  const a = document.createElement("a");244  a.download = "peach-recovery-words.txt";245  a.href = URL.createObjectURL(blob);246  setTimeout(() => URL.revokeObjectURL(a.href), 1000);247  document.body.appendChild(a);248  a.click();249  a.remove();250}