RecoveryWordDisplay.tsx

9.4 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";5import { isNativePlatform } from "../../shared/native";67interface RecoveryWordDisplayViewProps {8  onDone: () => void;9}1011// Three random positions are confirmed each time this screen mounts, so a12// user can't get away with only memorizing a fixed set of slots.13function pickConfirmIndices(): number[] {14  const pool = Array.from({ length: 12 }, (_, i) => i);15  const rand = new Uint32Array(pool.length);16  crypto.getRandomValues(rand);17  for (let i = pool.length - 1; i > 0; i--) {18    const j = (rand[i] ?? 0) % (i + 1);19    const tmp = pool[i]!;20    pool[i] = pool[j]!;21    pool[j] = tmp;22  }23  return pool.slice(0, 3).sort((a, b) => a - b);24}2526export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {27  // Chosen once per mount; the labels and the validation must agree.28  const confirmIndices = pickConfirmIndices();29  const [words, setWords] = createSignal<string[]>([]);30  const [error, setError] = createSignal("");31  const [loading, setLoading] = createSignal(true);32  const [confirmWords, setConfirmWords] = createSignal(["", "", ""]);33  const [confirmError, setConfirmError] = createSignal("");34  const [confirmed, setConfirmed] = createSignal(false);35  // 5-second countdown before confirmation input is enabled36  const [countdown, setCountdown] = createSignal(5);37  const [copied, setCopied] = createSignal(false);3839  async function copyRecoveryWords() {40    try {41      await navigator.clipboard.writeText(recoveryWordsText(words()));42      setCopied(true);43      setTimeout(() => setCopied(false), 1800);44    } catch {45      /* clipboard denied — the words remain on screen for transcription */46    }47  }48  let countdownTimer: ReturnType<typeof setInterval> | null = null;4950  onMount(async () => {51    // Fetch the recovery words from the background (vault must be unlocked).52    const res = (await sendMessage({53      type: "GET_RECOVERY_WORDS",54    })) as { ok: boolean; data?: string[]; error?: string };5556    if (res.ok && res.data) {57      setWords(res.data);58    } else {59      setError("could not retrieve recovery words");60    }61    setLoading(false);6263    // Start 5-second countdown64    let remaining = 5;65    countdownTimer = setInterval(() => {66      remaining -= 1;67      setCountdown(remaining);68      if (remaining <= 0 && countdownTimer) {69        clearInterval(countdownTimer);70        countdownTimer = null;71      }72    }, 1000);73  });7475  onCleanup(() => {76    if (countdownTimer) clearInterval(countdownTimer);77  });7879  async function handleConfirm() {80    const confirmed = confirmWords().map((w) => w.toLowerCase().trim());81    if (confirmed.some((w) => !w)) {82      setConfirmError("all three words are required");83      return;84    }8586    // Verify against stored words87    const stored = words();88    for (let i = 0; i < confirmIndices.length; i++) {89      const pos = confirmIndices[i]!;90      if (confirmed[i] !== stored[pos]) {91        setConfirmError(`word ${pos + 1} is incorrect`);92        return;93      }94    }9596    setConfirmed(true);97    setConfirmError("");98    // Clear the recovery-pending flag so lock-on-close / auto-lock resume99    // their normal behavior and future unlocks no longer re-route here.100    // Once the user has confirmed the words, losing the in-memory key is101    // no longer a data-loss event.102    try {103      await chrome.storage.local?.remove("peach_recovery_pending");104    } catch { /* best-effort */ }105    // Small pause so user sees the success state, then proceed.106    await new Promise((r) => setTimeout(r, 600));107    props.onDone();108  }109110  const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());111112  return (113    <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">114      <RecoveryRuneBackground />115116      <header class="peach-screen-header">117        <div class="peach-wordmark-stack">118          <PeachWordmark class="peach-wordmark-wrap" />119        </div>120      </header>121122      <div class="peach-recovery-header">123        <h1 class="peach-onboarding-headline">save your recovery words</h1>124        <p class="peach-hint peach-hint--warning">125          write these down — you can view them again in Settings with your master password126        </p>127      </div>128129      <Show when={loading()}>130        <p class="peach-hint">loading...</p>131      </Show>132133      <Show when={error()}>134        <p class="peach-hint peach-hint-error">{error()}</p>135      </Show>136137      <Show when={!loading() && !error()}>138        <div class="peach-recovery-words-display">139          <div class="peach-recovery-words-label">your 12 recovery words</div>140          <ol class="peach-recovery-word-list" start={1}>141            <For each={words()}>142              {(word, i) => (143                <li class="peach-recovery-word-item">144                  <span class="peach-recovery-word-num">{i() + 1}</span>145                  <span class="peach-recovery-word-text">{word}</span>146                </li>147              )}148            </For>149          </ol>150          {/* On native we must NOT write the master recovery secret to a file:151              the Android download sink lands in world-readable public Downloads152              (audit MEDIUM). Offer a transient clipboard copy the user routes153              into their own secure store instead. Desktop keeps the154              user-chosen-location .txt download. */}155          <Show156            when={isNativePlatform()}157            fallback={158              <button159                type="button"160                class="peach-recovery-download-btn"161                onClick={() => void saveRecoveryWords(words())}162              >163                download as .txt164              </button>165            }166          >167            <button168              type="button"169              class="peach-recovery-download-btn"170              onClick={() => void copyRecoveryWords()}171            >172              {copied() ? "copied ✓ — paste somewhere safe" : "copy words"}173            </button>174          </Show>175        </div>176177        {/* Confirmation step */}178        <div class="peach-recovery-confirm">179          <Show when={!confirmed()}>180            <p class="peach-recovery-confirm-label">181              {countdown() > 0182                ? `type the highlighted words to confirm (${countdown()}s)`183                : "type the highlighted words to confirm"}184            </p>185          </Show>186          <Show when={confirmed()}>187            <p class="peach-hint peach-hint--success">✓ words verified</p>188          </Show>189190          <div class="peach-recovery-confirm-fields">191            <For each={confirmIndices}>192              {(pos, i) => (193                <div class="peach-recovery-confirm-field">194                  <span class="peach-recovery-confirm-num">195                    word {pos + 1}196                  </span>197                  <input198                    class="peach-recovery-word-input peach-recovery-confirm-input"199                    type="text"200                    autocomplete="off"201                    autocorrect="off"202                    autocapitalize="none"203                    spellcheck={false}204                    disabled={!canConfirm()}205                    value={confirmWords()[i()]}206                    onInput={(e) => {207                      const updated = [...confirmWords()];208                      updated[i()] = (e.target as HTMLInputElement).value;209                      setConfirmWords(updated);210                      if (confirmError()) setConfirmError("");211                    }}212                    onKeyDown={(e) => {213                      if (e.key === "Enter" && canConfirm()) void handleConfirm();214                    }}215                  />216                </div>217              )}218            </For>219          </div>220221          <Show when={confirmError()}>222            <p class="peach-hint peach-hint-error">{confirmError()}</p>223          </Show>224225          <button226            class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"227            disabled={!canConfirm() || confirmed()}228            onClick={() => void handleConfirm()}229          >230            {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}231          </button>232        </div>233      </Show>234    </section>235  );236}237238function recoveryWordsText(words: string[]): string {239  const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");240  return (241    "PEACH RECOVERY WORDS\n" +242    "====================\n\n" +243    "Anyone with these 12 words can recover your vault.\n" +244    "Store this somewhere only you can access.\n\n" +245    numbered + "\n"246  );247}248249// Desktop only — writes via a blob to a user-chosen save location. The native250// path deliberately does NOT use a file download: on Android the data:-URL251// download sink writes to world-readable public Downloads (audit MEDIUM), so252// native offers a clipboard copy (copyRecoveryWords) the user routes instead.253function saveRecoveryWords(words: string[]): void {254  const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });255  const a = document.createElement("a");256  a.download = "peach-recovery-words.txt";257  a.href = URL.createObjectURL(blob);258  setTimeout(() => URL.revokeObjectURL(a.href), 1000);259  document.body.appendChild(a);260  a.click();261  a.remove();262}