CJKExport.tsx

22.0 KB · raw

1/**2 * CJK phrase export flow (spec §7).3 *4 * Three screens:5 *   1. passphrase entry + strength + confirm + high-security + generate6 *   2. generating (progress labels as each pipeline step runs)7 *   3. phrase display (copy / save-as-file / done)8 *9 * The crypto pipeline (`exportToCJK`) lives in src/crypto. This component10 * is purely UX — it drives the pipeline and reports progress. A11 * passphrase validation error should never appear on screen 2 or 3 since12 * the Export button is disabled until all §6.1 requirements pass.13 */1415import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";1617import {18  ARGON2_DEFAULT,19  exportToCJK,20} from "../../crypto/cjkExport";21import {22  checkPassphraseBreached,23  evaluateStrength,24  generateDicewarePassphrase,25  MIN_PASSPHRASE_LENGTH,26  validateForExport,27  type ExportPassphraseErrorCode,28  type HibpResult,29  type StrengthTier,30} from "../../crypto/cjkPassphrase";31import type { VaultEntry } from "../../shared/types";3233import { sendMessage } from "../App";3435// ---------------------------------------------------------------------------36// Error code → user-facing copy (spec §9)37// ---------------------------------------------------------------------------3839const EXPORT_ERROR_COPY: Record<ExportPassphraseErrorCode, string> = {40  PASSPHRASE_TOO_SHORT: "Your export passphrase must be at least 16 characters.",41  PASSPHRASE_TOO_WEAK:42    "Your export passphrase is too easy to guess. Try a random phrase of 5–6 words.",43  PASSPHRASE_BREACHED:44    "This passphrase has appeared in a known data breach. Choose a different one.",45  PASSPHRASE_MISMATCH: "Passphrases don't match. Re-enter them carefully.",46};4748const STRENGTH_COLOUR: Record<StrengthTier, string> = {49  weak: "#DC503C",50  fair: "#D96830",51  good: "#64D264",52  excellent: "#5090D8",53};5455const STRENGTH_LABEL: Record<StrengthTier, string> = {56  weak: "weak",57  fair: "fair",58  good: "good",59  excellent: "excellent",60};6162// ---------------------------------------------------------------------------63// Component64// ---------------------------------------------------------------------------6566type Screen = "passphrase" | "generating" | "phrase";6768type ProgressStage =69  | "idle"70  | "deriving-key"71  | "encrypting"72  | "encoding"73  | "done";7475const PROGRESS_COPY: Record<ProgressStage, string> = {76  idle: "",77  "deriving-key": "Deriving key…",78  encrypting: "Encrypting…",79  encoding: "Encoding…",80  done: "Done.",81};8283interface CJKExportProps {84  onBack: () => void;85  onRequiresUnlock?: () => void;86}8788function getPipelineErrorCopy(error: string | undefined): string {89  if (!error) return "Export failed.";90  if (error === "EMPTY_VAULT_EXPORT") {91    return "Your vault has no entries to export.";92  }93  if (error === "vault locked" || error === "VAULT_LOCKED") {94    return "Peach locked before the export could start. Unlock it again and retry.";95  }96  if (error === "SERVICE_WORKER_UNAVAILABLE") {97    return "Peach couldn't reach its background worker. Reopen the popup and try again.";98  }99  return `Export failed: ${error}`;100}101102function isLockRecoveryError(error: string | undefined): boolean {103  return (104    error === "vault locked" ||105    error === "VAULT_LOCKED" ||106    error === "SERVICE_WORKER_UNAVAILABLE"107  );108}109110export function CJKExportView(props: CJKExportProps) {111  const [screen, setScreen] = createSignal<Screen>("passphrase");112113  const [passphrase, setPassphrase] = createSignal("");114  const [confirmPhrase, setConfirmPhrase] = createSignal("");115116  const [hibp, setHibp] = createSignal<HibpResult | null>(null);117  const [hibpChecking, setHibpChecking] = createSignal(false);118  let hibpController: AbortController | null = null;119  let hibpDebounce: ReturnType<typeof setTimeout> | null = null;120121  const [progress, setProgress] = createSignal<ProgressStage>("idle");122  const [phraseText, setPhraseText] = createSignal("");123  const [entryCount, setEntryCount] = createSignal(0);124  const [cjkCharCount, setCjkCharCount] = createSignal(0);125  const [passkeyCount, setPasskeyCount] = createSignal(0);126  const [noteCount, setNoteCount] = createSignal(0);127  const [loginCount, setLoginCount] = createSignal(0);128  const [copied, setCopied] = createSignal(false);129  const [pipelineError, setPipelineError] = createSignal<string | null>(null);130131  const [showQrOverlay, setShowQrOverlay] = createSignal(false);132  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);133134  createEffect(() => {135    if (!showQrOverlay()) return;136    const handler = (e: KeyboardEvent) => {137      if (e.key === "Escape") handleCloseQr();138    };139    window.addEventListener("keydown", handler);140    onCleanup(() => window.removeEventListener("keydown", handler));141  });142143  // -------------------------------------------------------------------------144  // Strength + validation memo — recomputed on every input145  // -------------------------------------------------------------------------146147  const strength = createMemo(() => evaluateStrength(passphrase()));148149  const liveValidation = createMemo(() =>150    validateForExport({151      passphrase: passphrase(),152      confirm: confirmPhrase(),153      hibp: hibp() ?? undefined,154    })155  );156157  const canExport = createMemo(() => liveValidation().ok);158159  // -------------------------------------------------------------------------160  // HIBP: debounced on passphrase change (spec §6.3 non-blocking)161  // -------------------------------------------------------------------------162163  function schedulePwnedCheck(value: string) {164    if (hibpController) hibpController.abort();165    if (hibpDebounce) clearTimeout(hibpDebounce);166    setHibp(null);167    setHibpChecking(false);168169    if (value.length < MIN_PASSPHRASE_LENGTH) return; // don't burn HIBP requests on tiny inputs170171    hibpDebounce = setTimeout(() => {172      const controller = new AbortController();173      hibpController = controller;174      setHibpChecking(true);175      checkPassphraseBreached(value, controller.signal)176        .then((result) => {177          // Only apply if the input hasn't changed underneath us.178          if (passphrase() === value) {179            setHibp(result);180          }181        })182        .catch(() => {183          // AbortError — user kept typing. Silent.184        })185        .finally(() => {186          if (hibpController === controller) {187            setHibpChecking(false);188          }189        });190    }, 500);191  }192193  onCleanup(() => {194    if (hibpController) hibpController.abort();195    if (hibpDebounce) clearTimeout(hibpDebounce);196  });197198  function handlePassphraseInput(value: string) {199    setPassphrase(value);200    setHibp(null);201    schedulePwnedCheck(value);202  }203204  function handleGenerate() {205    const generated = generateDicewarePassphrase(6);206    setPassphrase(generated);207    setConfirmPhrase(generated); // auto-confirm so user only needs to copy it208    schedulePwnedCheck(generated);209  }210211  // -------------------------------------------------------------------------212  // Export pipeline driver213  // -------------------------------------------------------------------------214215  async function runExport() {216    if (!canExport()) return;217    setScreen("generating");218    setProgress("deriving-key");219    setPipelineError(null);220221    try {222      // 1. Fetch entries + settings from the background vault223      const [entriesRes, settingsRes] = await Promise.all([224        sendMessage({ type: "GET_ENTRIES" }),225        sendMessage({ type: "GET_SETTINGS" }),226      ]);227      const entriesResponse = entriesRes as {228        ok: boolean;229        data?: VaultEntry[];230        error?: string;231      };232      const settingsResponse = settingsRes as {233        ok: boolean;234        data?: Record<string, unknown>;235        error?: string;236      };237      if (!entriesResponse.ok) {238        throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");239      }240      if (!settingsResponse.ok) {241        throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");242      }243244      const entries = (entriesResponse.data ?? []).filter((entry) => !entry.deletedAt);245      if (entries.length === 0) {246        throw new Error("EMPTY_VAULT_EXPORT");247      }248249      const settings = settingsResponse.data ?? {};250251      setProgress("deriving-key");252      await new Promise((r) => setTimeout(r, 0));253254      const result = await exportToCJK({255        entries,256        settings: settings as any,257        passphrase: passphrase(),258        argon2: ARGON2_DEFAULT,259      });260261      setProgress("encoding");262      await new Promise((r) => setTimeout(r, 0));263264      setPhraseText(result.phrase);265      setEntryCount(result.entryCount);266      setCjkCharCount(result.cjkCharCount);267      setLoginCount(result.loginCount);268      setNoteCount(result.noteCount);269      setPasskeyCount(result.passkeyCount);270      setProgress("done");271      setScreen("phrase");272    } catch (err) {273      const message = err instanceof Error ? err.message : String(err);274      if (isLockRecoveryError(message)) {275        props.onRequiresUnlock?.();276        return;277      }278      setPipelineError(getPipelineErrorCopy(message));279      setScreen("passphrase");280      setProgress("idle");281    }282  }283284  // -------------------------------------------------------------------------285  // Phrase-screen actions286  // -------------------------------------------------------------------------287288  async function handleCopy() {289    try {290      await navigator.clipboard.writeText(phraseText());291      setCopied(true);292      setTimeout(() => setCopied(false), 2000);293    } catch {294      // Clipboard blocked — fall back to a manual selection prompt.295      setCopied(false);296    }297  }298299  function handleSaveFile() {300    const dateStamp = new Date().toISOString().split("T")[0];301    const filename = `peach-backup-${dateStamp}.txt`;302    const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });303    const url = URL.createObjectURL(blob);304    const a = document.createElement("a");305    a.href = url;306    a.download = filename;307    a.click();308    URL.revokeObjectURL(url);309  }310311  async function handleShowQr() {312    const byteLen = new TextEncoder().encode(phraseText()).length;313    if (byteLen > 2800) return;314    setShowQrOverlay(true);315    const { default: QRCode } = await import("qrcode");316    const dataUrl = await QRCode.toDataURL(phraseText(), {317      errorCorrectionLevel: "L",318      width: 320,319      margin: 2,320      color: { dark: "#E8A87C", light: "#00000000" },321    });322    setQrDataUrl(dataUrl);323  }324325  function handleCloseQr() {326    setShowQrOverlay(false);327    setQrDataUrl(null);328  }329330  function handleDone() {331    setPassphrase("");332    setConfirmPhrase("");333    setPhraseText("");334    setShowQrOverlay(false);335    setQrDataUrl(null);336    props.onBack();337  }338339  // -------------------------------------------------------------------------340  // Render341  // -------------------------------------------------------------------------342343  return (344    <div class="peach-overlay-view ov-enter-right">345      <div class="peach-overlay-header">346        <button347          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"348          onClick={screen() === "phrase" ? handleDone : props.onBack}349          aria-label="Back"350          disabled={screen() === "generating"}351        >352          <svg353            width="7"354            height="12"355            viewBox="0 0 7 12"356            fill="none"357            stroke="currentColor"358            stroke-width="1.1"359            stroke-linecap="round"360            stroke-linejoin="round"361          >362            <path d="M6 1L1 6L6 11" />363          </svg>364        </button>365        <span class="peach-overlay-title">366          {screen() === "phrase" ? "backup phrase" : "export"}367        </span>368        <div class="peach-overlay-spacer"></div>369      </div>370371      <div class="peach-settings-body">372        <Show when={screen() === "passphrase"}>373          <PassphraseScreen374            passphrase={passphrase()}375            onPassphraseInput={handlePassphraseInput}376            confirm={confirmPhrase()}377            onConfirmInput={setConfirmPhrase}378            strengthTier={strength().tier}379            strengthScore={strength().score}380            strengthLabel={strength().feedback}381            hibp={hibp()}382            hibpChecking={hibpChecking()}383            error={liveValidation().error}384            canExport={canExport()}385            onGenerate={handleGenerate}386            onExport={() => void runExport()}387            pipelineError={pipelineError()}388          />389        </Show>390391        <Show when={screen() === "generating"}>392          <GeneratingScreen stage={progress()} />393        </Show>394395        <Show when={screen() === "phrase"}>396          <PhraseScreen397            phrase={phraseText()}398            entryCount={entryCount()}399            cjkCharCount={cjkCharCount()}400            loginCount={loginCount()}401            noteCount={noteCount()}402            passkeyCount={passkeyCount()}403            copied={copied()}404            onCopy={() => void handleCopy()}405            onSaveFile={handleSaveFile}406            onDone={handleDone}407            onShowQr={() => void handleShowQr()}408            showQrOverlay={showQrOverlay()}409            qrDataUrl={qrDataUrl()}410            onCloseQr={handleCloseQr}411          />412        </Show>413      </div>414    </div>415  );416}417418// ---------------------------------------------------------------------------419// Screen 1: passphrase entry420// ---------------------------------------------------------------------------421422interface PassphraseScreenProps {423  passphrase: string;424  onPassphraseInput: (value: string) => void;425  confirm: string;426  onConfirmInput: (value: string) => void;427  strengthTier: StrengthTier;428  strengthScore: number;429  strengthLabel: string;430  hibp: HibpResult | null;431  hibpChecking: boolean;432  error?: ExportPassphraseErrorCode;433  canExport: boolean;434  onGenerate: () => void;435  onExport: () => void;436  pipelineError: string | null;437}438439function PassphraseScreen(props: PassphraseScreenProps) {440  const barFillPercent = () => {441    if (props.passphrase.length === 0) return 0;442    return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;443  };444445  const errorMessage = () =>446    props.error && props.passphrase.length > 0447      ? EXPORT_ERROR_COPY[props.error]448      : null;449450  const confirmMatches = () =>451    props.confirm.length > 0 && props.confirm === props.passphrase;452453  return (454    <>455      <div class="peach-settings-group">456        <p class="peach-settings-label">choose a passphrase</p>457        <p class="peach-settings-copy peach-settings-copy--spacious">458          Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.459        </p>460461        <div class="peach-settings-actions" style="margin-bottom:8px">462          <button463            class="peach-btn peach-btn--ghost peach-btn--sm"464            onClick={props.onGenerate}465          >466            ↻ generate one for me467          </button>468        </div>469470        <input471          class="peach-settings-input"472          type="text"473          placeholder="passphrase"474          value={props.passphrase}475          onInput={(e) =>476            props.onPassphraseInput((e.target as HTMLInputElement).value)477          }478        />479480        <Show when={props.passphrase.length > 0}>481          <div class="peach-strength-row">482            <div class="peach-strength-track">483              <div484                class="peach-strength-fill"485                style={{486                  width: `${barFillPercent()}%`,487                  background: STRENGTH_COLOUR[props.strengthTier],488                }}489              />490            </div>491            <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>492              {STRENGTH_LABEL[props.strengthTier]}493            </span>494          </div>495        </Show>496497        <Show when={props.passphrase.length > 0 && !confirmMatches()}>498          <input499            class="peach-settings-input"500            type="text"501            placeholder="confirm passphrase"502            value={props.confirm}503            onInput={(e) =>504              props.onConfirmInput((e.target as HTMLInputElement).value)505            }506          />507        </Show>508509        <Show when={confirmMatches()}>510          <div class="peach-inline-feedback peach-inline-feedback--ok">511            ✓ passphrases match512          </div>513        </Show>514515        <Show when={errorMessage()}>516          <div class="peach-inline-feedback peach-inline-feedback--error">517            {errorMessage()}518          </div>519        </Show>520521        <Show when={props.hibpChecking}>522          <div class="peach-inline-feedback peach-inline-feedback--muted">523            Checking passphrase…524          </div>525        </Show>526        <Show when={props.hibp && !props.hibp.apiReachable}>527          <div class="peach-inline-feedback peach-inline-feedback--muted">528            Couldn't reach breach database — proceed with care.529          </div>530        </Show>531532        <Show when={props.pipelineError}>533          <div class="peach-inline-feedback peach-inline-feedback--error">534            {props.pipelineError}535          </div>536        </Show>537538        <div class="peach-settings-actions" style="margin-top:10px">539          <button540            class="peach-btn peach-btn--subtle peach-btn--sm"541            onClick={props.onExport}542            disabled={!props.canExport}543          >544            Create backup phrase →545          </button>546        </div>547      </div>548    </>549  );550}551552// ---------------------------------------------------------------------------553// Screen 2: generating554// ---------------------------------------------------------------------------555556interface GeneratingScreenProps {557  stage: ProgressStage;558}559560function GeneratingScreen(props: GeneratingScreenProps) {561  return (562    <div class="peach-settings-group peach-progress-screen">563      <div class="peach-progress-screen-title">Creating your backup phrase…</div>564      <div class="peach-spinner" aria-label="Working" />565      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>566    </div>567  );568}569570// ---------------------------------------------------------------------------571// Screen 3: phrase display572// ---------------------------------------------------------------------------573574interface PhraseScreenProps {575  phrase: string;576  entryCount: number;577  cjkCharCount: number;578  loginCount: number;579  noteCount: number;580  passkeyCount: number;581  copied: boolean;582  onCopy: () => void;583  onSaveFile: () => void;584  onDone: () => void;585  onShowQr: () => void;586  showQrOverlay: boolean;587  qrDataUrl: string | null;588  onCloseQr: () => void;589}590591function PhraseScreen(props: PhraseScreenProps) {592  const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;593594  return (595    <>596      <div class="peach-settings-group">597        <p class="peach-settings-label">your backup phrase</p>598        <p class="peach-settings-copy peach-settings-copy--spacious">599          Copy this phrase and store it somewhere safe. You need this phrase600          and your passphrase to restore your vault.601        </p>602603        <div class="peach-cjk-phrase-box">{props.phrase}</div>604605        <div class="peach-inline-feedback peach-inline-feedback--muted">606          {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} ·{" "}607          {props.cjkCharCount.toLocaleString()} characters608        </div>609        <div class="peach-inline-feedback peach-inline-feedback--muted">610          {props.loginCount} login{props.loginCount === 1 ? "" : "s"}611          {props.noteCount > 0612            ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`613            : ""}614          {props.passkeyCount > 0615            ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`616            : ""}617          {" · settings included"}618        </div>619620        <div class="peach-settings-actions">621          <button622            class="peach-btn peach-btn--subtle peach-btn--sm"623            onClick={props.onCopy}624          >625            {props.copied ? "Copied ✓" : "Copy phrase"}626          </button>627          <button628            class="peach-btn peach-btn--ghost peach-btn--sm"629            onClick={props.onSaveFile}630          >631            Save as file632          </button>633        </div>634635        <Show when={phraseTooLongForQr()}>636          <div class="peach-settings-copy" style="margin-top:12px">637            Your vault is too large to fit in a single QR code. Copy the phrase above and paste it into Peach on your other device.638          </div>639        </Show>640        <Show when={!phraseTooLongForQr()}>641          <div class="peach-settings-stack" style="margin-top:12px">642            <button643              class="peach-btn peach-btn--ghost peach-btn--sm"644              onClick={props.onShowQr}645            >646              Show as QR647            </button>648          </div>649        </Show>650651        <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">652          <button653            class="peach-btn peach-btn--ghost peach-btn--sm"654            onClick={props.onDone}655          >656            Done657          </button>658        </div>659      </div>660661      <Show when={props.showQrOverlay && props.qrDataUrl}>662        <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>663          <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>664            <div class="peach-overlay-header">665              <span class="peach-overlay-title">scan with Peach</span>666              <div class="peach-overlay-spacer" />667              <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">668                <CloseGlyph />669              </button>670            </div>671            <div class="peach-cjk-qr-body">672              <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>673              <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />674            </div>675          </div>676        </div>677      </Show>678    </>679  );680}681682function CloseGlyph() {683  return (684    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">685      <line x1="2" y1="2" x2="10" y2="10" />686      <line x1="10" y1="2" x2="2" y2="10" />687    </svg>688  );689}