CJKExport.tsx

21.7 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}8687function getPipelineErrorCopy(error: string | undefined): string {88  if (!error) return "Export failed.";89  if (error === "EMPTY_VAULT_EXPORT") {90    return "Your vault has no entries to export.";91  }92  if (error === "vault locked" || error === "VAULT_LOCKED") {93    return "Peach locked before the export could start. Unlock it again and retry.";94  }95  if (error === "SERVICE_WORKER_UNAVAILABLE") {96    return "Peach couldn't reach its background worker. Reopen the popup and try again.";97  }98  return `Export failed: ${error}`;99}100101export function CJKExportView(props: CJKExportProps) {102  const [screen, setScreen] = createSignal<Screen>("passphrase");103104  const [passphrase, setPassphrase] = createSignal("");105  const [confirmPhrase, setConfirmPhrase] = createSignal("");106107  const [hibp, setHibp] = createSignal<HibpResult | null>(null);108  const [hibpChecking, setHibpChecking] = createSignal(false);109  let hibpController: AbortController | null = null;110  let hibpDebounce: ReturnType<typeof setTimeout> | null = null;111112  const [progress, setProgress] = createSignal<ProgressStage>("idle");113  const [phraseText, setPhraseText] = createSignal("");114  const [entryCount, setEntryCount] = createSignal(0);115  const [cjkCharCount, setCjkCharCount] = createSignal(0);116  const [passkeyCount, setPasskeyCount] = createSignal(0);117  const [noteCount, setNoteCount] = createSignal(0);118  const [loginCount, setLoginCount] = createSignal(0);119  const [copied, setCopied] = createSignal(false);120  const [pipelineError, setPipelineError] = createSignal<string | null>(null);121122  const [showQrOverlay, setShowQrOverlay] = createSignal(false);123  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);124125  createEffect(() => {126    if (!showQrOverlay()) return;127    const handler = (e: KeyboardEvent) => {128      if (e.key === "Escape") handleCloseQr();129    };130    window.addEventListener("keydown", handler);131    onCleanup(() => window.removeEventListener("keydown", handler));132  });133134  // -------------------------------------------------------------------------135  // Strength + validation memo — recomputed on every input136  // -------------------------------------------------------------------------137138  const strength = createMemo(() => evaluateStrength(passphrase()));139140  const liveValidation = createMemo(() =>141    validateForExport({142      passphrase: passphrase(),143      confirm: confirmPhrase(),144      hibp: hibp() ?? undefined,145    })146  );147148  const canExport = createMemo(() => liveValidation().ok);149150  // -------------------------------------------------------------------------151  // HIBP: debounced on passphrase change (spec §6.3 non-blocking)152  // -------------------------------------------------------------------------153154  function schedulePwnedCheck(value: string) {155    if (hibpController) hibpController.abort();156    if (hibpDebounce) clearTimeout(hibpDebounce);157    setHibp(null);158    setHibpChecking(false);159160    if (value.length < MIN_PASSPHRASE_LENGTH) return; // don't burn HIBP requests on tiny inputs161162    hibpDebounce = setTimeout(() => {163      const controller = new AbortController();164      hibpController = controller;165      setHibpChecking(true);166      checkPassphraseBreached(value, controller.signal)167        .then((result) => {168          // Only apply if the input hasn't changed underneath us.169          if (passphrase() === value) {170            setHibp(result);171          }172        })173        .catch(() => {174          // AbortError — user kept typing. Silent.175        })176        .finally(() => {177          if (hibpController === controller) {178            setHibpChecking(false);179          }180        });181    }, 500);182  }183184  onCleanup(() => {185    if (hibpController) hibpController.abort();186    if (hibpDebounce) clearTimeout(hibpDebounce);187  });188189  function handlePassphraseInput(value: string) {190    setPassphrase(value);191    setHibp(null);192    schedulePwnedCheck(value);193  }194195  function handleGenerate() {196    const generated = generateDicewarePassphrase(6);197    setPassphrase(generated);198    setConfirmPhrase(generated); // auto-confirm so user only needs to copy it199    schedulePwnedCheck(generated);200  }201202  // -------------------------------------------------------------------------203  // Export pipeline driver204  // -------------------------------------------------------------------------205206  async function runExport() {207    if (!canExport()) return;208    setScreen("generating");209    setProgress("deriving-key");210    setPipelineError(null);211212    try {213      // 1. Fetch entries + settings from the background vault214      const [entriesRes, settingsRes] = await Promise.all([215        sendMessage({ type: "GET_ENTRIES" }),216        sendMessage({ type: "GET_SETTINGS" }),217      ]);218      const entriesResponse = entriesRes as {219        ok: boolean;220        data?: VaultEntry[];221        error?: string;222      };223      const settingsResponse = settingsRes as {224        ok: boolean;225        data?: Record<string, unknown>;226        error?: string;227      };228      if (!entriesResponse.ok) {229        throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");230      }231      if (!settingsResponse.ok) {232        throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");233      }234235      const entries = (entriesResponse.data ?? []).filter((entry) => !entry.deletedAt);236      if (entries.length === 0) {237        throw new Error("EMPTY_VAULT_EXPORT");238      }239240      const settings = settingsResponse.data ?? {};241242      setProgress("deriving-key");243      await new Promise((r) => setTimeout(r, 0));244245      const result = await exportToCJK({246        entries,247        settings: settings as any,248        passphrase: passphrase(),249        argon2: ARGON2_DEFAULT,250      });251252      setProgress("encoding");253      await new Promise((r) => setTimeout(r, 0));254255      setPhraseText(result.phrase);256      setEntryCount(result.entryCount);257      setCjkCharCount(result.cjkCharCount);258      setLoginCount(result.loginCount);259      setNoteCount(result.noteCount);260      setPasskeyCount(result.passkeyCount);261      setProgress("done");262      setScreen("phrase");263    } catch (err) {264      const message = err instanceof Error ? err.message : String(err);265      setPipelineError(getPipelineErrorCopy(message));266      setScreen("passphrase");267      setProgress("idle");268    }269  }270271  // -------------------------------------------------------------------------272  // Phrase-screen actions273  // -------------------------------------------------------------------------274275  async function handleCopy() {276    try {277      await navigator.clipboard.writeText(phraseText());278      setCopied(true);279      setTimeout(() => setCopied(false), 2000);280    } catch {281      // Clipboard blocked — fall back to a manual selection prompt.282      setCopied(false);283    }284  }285286  function handleSaveFile() {287    const dateStamp = new Date().toISOString().split("T")[0];288    const filename = `peach-backup-${dateStamp}.txt`;289    const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });290    const url = URL.createObjectURL(blob);291    const a = document.createElement("a");292    a.href = url;293    a.download = filename;294    a.click();295    URL.revokeObjectURL(url);296  }297298  async function handleShowQr() {299    const byteLen = new TextEncoder().encode(phraseText()).length;300    if (byteLen > 2800) return;301    setShowQrOverlay(true);302    const { default: QRCode } = await import("qrcode");303    const dataUrl = await QRCode.toDataURL(phraseText(), {304      errorCorrectionLevel: "L",305      width: 320,306      margin: 2,307      color: { dark: "#E8A87C", light: "#00000000" },308    });309    setQrDataUrl(dataUrl);310  }311312  function handleCloseQr() {313    setShowQrOverlay(false);314    setQrDataUrl(null);315  }316317  function handleDone() {318    setPassphrase("");319    setConfirmPhrase("");320    setPhraseText("");321    setShowQrOverlay(false);322    setQrDataUrl(null);323    props.onBack();324  }325326  // -------------------------------------------------------------------------327  // Render328  // -------------------------------------------------------------------------329330  return (331    <div class="peach-overlay-view ov-enter-right">332      <div class="peach-overlay-header">333        <button334          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"335          onClick={screen() === "phrase" ? handleDone : props.onBack}336          aria-label="Back"337          disabled={screen() === "generating"}338        >339          <svg340            width="7"341            height="12"342            viewBox="0 0 7 12"343            fill="none"344            stroke="currentColor"345            stroke-width="1.1"346            stroke-linecap="round"347            stroke-linejoin="round"348          >349            <path d="M6 1L1 6L6 11" />350          </svg>351        </button>352        <span class="peach-overlay-title">353          {screen() === "phrase" ? "backup phrase" : "export"}354        </span>355        <div class="peach-overlay-spacer"></div>356      </div>357358      <div class="peach-settings-body">359        <Show when={screen() === "passphrase"}>360          <PassphraseScreen361            passphrase={passphrase()}362            onPassphraseInput={handlePassphraseInput}363            confirm={confirmPhrase()}364            onConfirmInput={setConfirmPhrase}365            strengthTier={strength().tier}366            strengthScore={strength().score}367            strengthLabel={strength().feedback}368            hibp={hibp()}369            hibpChecking={hibpChecking()}370            error={liveValidation().error}371            canExport={canExport()}372            onGenerate={handleGenerate}373            onExport={() => void runExport()}374            pipelineError={pipelineError()}375          />376        </Show>377378        <Show when={screen() === "generating"}>379          <GeneratingScreen stage={progress()} />380        </Show>381382        <Show when={screen() === "phrase"}>383          <PhraseScreen384            phrase={phraseText()}385            entryCount={entryCount()}386            cjkCharCount={cjkCharCount()}387            loginCount={loginCount()}388            noteCount={noteCount()}389            passkeyCount={passkeyCount()}390            copied={copied()}391            onCopy={() => void handleCopy()}392            onSaveFile={handleSaveFile}393            onDone={handleDone}394            onShowQr={() => void handleShowQr()}395            showQrOverlay={showQrOverlay()}396            qrDataUrl={qrDataUrl()}397            onCloseQr={handleCloseQr}398          />399        </Show>400      </div>401    </div>402  );403}404405// ---------------------------------------------------------------------------406// Screen 1: passphrase entry407// ---------------------------------------------------------------------------408409interface PassphraseScreenProps {410  passphrase: string;411  onPassphraseInput: (value: string) => void;412  confirm: string;413  onConfirmInput: (value: string) => void;414  strengthTier: StrengthTier;415  strengthScore: number;416  strengthLabel: string;417  hibp: HibpResult | null;418  hibpChecking: boolean;419  error?: ExportPassphraseErrorCode;420  canExport: boolean;421  onGenerate: () => void;422  onExport: () => void;423  pipelineError: string | null;424}425426function PassphraseScreen(props: PassphraseScreenProps) {427  const barFillPercent = () => {428    if (props.passphrase.length === 0) return 0;429    return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;430  };431432  const errorMessage = () =>433    props.error && props.passphrase.length > 0434      ? EXPORT_ERROR_COPY[props.error]435      : null;436437  const confirmMatches = () =>438    props.confirm.length > 0 && props.confirm === props.passphrase;439440  return (441    <>442      <div class="peach-settings-group">443        <p class="peach-settings-label">choose a passphrase</p>444        <p class="peach-settings-copy peach-settings-copy--spacious">445          Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.446        </p>447448        <div class="peach-settings-actions" style="margin-bottom:8px">449          <button450            class="peach-btn peach-btn--ghost peach-btn--sm"451            onClick={props.onGenerate}452          >453            ↻ generate one for me454          </button>455        </div>456457        <input458          class="peach-settings-input"459          type="text"460          placeholder="passphrase"461          value={props.passphrase}462          onInput={(e) =>463            props.onPassphraseInput((e.target as HTMLInputElement).value)464          }465        />466467        <Show when={props.passphrase.length > 0}>468          <div class="peach-strength-row">469            <div class="peach-strength-track">470              <div471                class="peach-strength-fill"472                style={{473                  width: `${barFillPercent()}%`,474                  background: STRENGTH_COLOUR[props.strengthTier],475                }}476              />477            </div>478            <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>479              {STRENGTH_LABEL[props.strengthTier]}480            </span>481          </div>482        </Show>483484        <Show when={props.passphrase.length > 0 && !confirmMatches()}>485          <input486            class="peach-settings-input"487            type="text"488            placeholder="confirm passphrase"489            value={props.confirm}490            onInput={(e) =>491              props.onConfirmInput((e.target as HTMLInputElement).value)492            }493          />494        </Show>495496        <Show when={confirmMatches()}>497          <div class="peach-inline-feedback peach-inline-feedback--ok">498            ✓ passphrases match499          </div>500        </Show>501502        <Show when={errorMessage()}>503          <div class="peach-inline-feedback peach-inline-feedback--error">504            {errorMessage()}505          </div>506        </Show>507508        <Show when={props.hibpChecking}>509          <div class="peach-inline-feedback peach-inline-feedback--muted">510            Checking passphrase…511          </div>512        </Show>513        <Show when={props.hibp && !props.hibp.apiReachable}>514          <div class="peach-inline-feedback peach-inline-feedback--muted">515            Couldn't reach breach database — proceed with care.516          </div>517        </Show>518519        <Show when={props.pipelineError}>520          <div class="peach-inline-feedback peach-inline-feedback--error">521            {props.pipelineError}522          </div>523        </Show>524525        <div class="peach-settings-actions" style="margin-top:10px">526          <button527            class="peach-btn peach-btn--subtle peach-btn--sm"528            onClick={props.onExport}529            disabled={!props.canExport}530          >531            Create backup phrase →532          </button>533        </div>534      </div>535    </>536  );537}538539// ---------------------------------------------------------------------------540// Screen 2: generating541// ---------------------------------------------------------------------------542543interface GeneratingScreenProps {544  stage: ProgressStage;545}546547function GeneratingScreen(props: GeneratingScreenProps) {548  return (549    <div class="peach-settings-group peach-progress-screen">550      <div class="peach-progress-screen-title">Creating your backup phrase…</div>551      <div class="peach-spinner" aria-label="Working" />552      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>553    </div>554  );555}556557// ---------------------------------------------------------------------------558// Screen 3: phrase display559// ---------------------------------------------------------------------------560561interface PhraseScreenProps {562  phrase: string;563  entryCount: number;564  cjkCharCount: number;565  loginCount: number;566  noteCount: number;567  passkeyCount: number;568  copied: boolean;569  onCopy: () => void;570  onSaveFile: () => void;571  onDone: () => void;572  onShowQr: () => void;573  showQrOverlay: boolean;574  qrDataUrl: string | null;575  onCloseQr: () => void;576}577578function PhraseScreen(props: PhraseScreenProps) {579  const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;580581  return (582    <>583      <div class="peach-settings-group">584        <p class="peach-settings-label">your backup phrase</p>585        <p class="peach-settings-copy peach-settings-copy--spacious">586          Copy this phrase and store it somewhere safe. You need this phrase587          and your passphrase to restore your vault.588        </p>589590        <div class="peach-cjk-phrase-box">{props.phrase}</div>591592        <div class="peach-inline-feedback peach-inline-feedback--muted">593          {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} ·{" "}594          {props.cjkCharCount.toLocaleString()} characters595        </div>596        <div class="peach-inline-feedback peach-inline-feedback--muted">597          {props.loginCount} login{props.loginCount === 1 ? "" : "s"}598          {props.noteCount > 0599            ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`600            : ""}601          {props.passkeyCount > 0602            ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`603            : ""}604          {" · settings included"}605        </div>606607        <div class="peach-settings-actions">608          <button609            class="peach-btn peach-btn--subtle peach-btn--sm"610            onClick={props.onCopy}611          >612            {props.copied ? "Copied ✓" : "Copy phrase"}613          </button>614          <button615            class="peach-btn peach-btn--ghost peach-btn--sm"616            onClick={props.onSaveFile}617          >618            Save as file619          </button>620        </div>621622        <Show when={phraseTooLongForQr()}>623          <div class="peach-settings-copy" style="margin-top:12px">624            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.625          </div>626        </Show>627        <Show when={!phraseTooLongForQr()}>628          <div class="peach-settings-stack" style="margin-top:12px">629            <button630              class="peach-btn peach-btn--ghost peach-btn--sm"631              onClick={props.onShowQr}632            >633              Show as QR634            </button>635          </div>636        </Show>637638        <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">639          <button640            class="peach-btn peach-btn--ghost peach-btn--sm"641            onClick={props.onDone}642          >643            Done644          </button>645        </div>646      </div>647648      <Show when={props.showQrOverlay && props.qrDataUrl}>649        <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>650          <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>651            <div class="peach-overlay-header">652              <span class="peach-overlay-title">scan with Peach</span>653              <div class="peach-overlay-spacer" />654              <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">655                <CloseGlyph />656              </button>657            </div>658            <div class="peach-cjk-qr-body">659              <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>660              <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />661            </div>662          </div>663        </div>664      </Show>665    </>666  );667}668669function CloseGlyph() {670  return (671    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">672      <line x1="2" y1="2" x2="10" y2="10" />673      <line x1="10" y1="2" x2="2" y2="10" />674    </svg>675  );676}