CJKExport.tsx

22.3 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 [trashedCount, setTrashedCount] = createSignal(0);129  const [copied, setCopied] = createSignal(false);130  const [pipelineError, setPipelineError] = createSignal<string | null>(null);131132  const [showQrOverlay, setShowQrOverlay] = createSignal(false);133  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);134135  createEffect(() => {136    if (!showQrOverlay()) return;137    const handler = (e: KeyboardEvent) => {138      if (e.key === "Escape") handleCloseQr();139    };140    window.addEventListener("keydown", handler);141    onCleanup(() => window.removeEventListener("keydown", handler));142  });143144  // -------------------------------------------------------------------------145  // Strength + validation memo — recomputed on every input146  // -------------------------------------------------------------------------147148  const strength = createMemo(() => evaluateStrength(passphrase()));149150  const liveValidation = createMemo(() =>151    validateForExport({152      passphrase: passphrase(),153      confirm: confirmPhrase(),154      hibp: hibp() ?? undefined,155    })156  );157158  const canExport = createMemo(() => liveValidation().ok);159160  // -------------------------------------------------------------------------161  // HIBP: debounced on passphrase change (spec §6.3 non-blocking)162  // -------------------------------------------------------------------------163164  function schedulePwnedCheck(value: string) {165    if (hibpController) hibpController.abort();166    if (hibpDebounce) clearTimeout(hibpDebounce);167    setHibp(null);168    setHibpChecking(false);169170    if (value.length < MIN_PASSPHRASE_LENGTH) return; // don't burn HIBP requests on tiny inputs171172    hibpDebounce = setTimeout(() => {173      const controller = new AbortController();174      hibpController = controller;175      setHibpChecking(true);176      checkPassphraseBreached(value, controller.signal)177        .then((result) => {178          // Only apply if the input hasn't changed underneath us.179          if (passphrase() === value) {180            setHibp(result);181          }182        })183        .catch(() => {184          // AbortError — user kept typing. Silent.185        })186        .finally(() => {187          if (hibpController === controller) {188            setHibpChecking(false);189          }190        });191    }, 500);192  }193194  onCleanup(() => {195    if (hibpController) hibpController.abort();196    if (hibpDebounce) clearTimeout(hibpDebounce);197  });198199  function handlePassphraseInput(value: string) {200    setPassphrase(value);201    setHibp(null);202    schedulePwnedCheck(value);203  }204205  function handleGenerate() {206    const generated = generateDicewarePassphrase(6);207    setPassphrase(generated);208    setConfirmPhrase(generated); // auto-confirm so user only needs to copy it209    schedulePwnedCheck(generated);210  }211212  // -------------------------------------------------------------------------213  // Export pipeline driver214  // -------------------------------------------------------------------------215216  async function runExport() {217    if (!canExport()) return;218    setScreen("generating");219    setProgress("deriving-key");220    setPipelineError(null);221222    try {223      // 1. Fetch entries + settings from the background vault224      const [entriesRes, settingsRes] = await Promise.all([225        sendMessage({ type: "GET_ENTRIES" }),226        sendMessage({ type: "GET_SETTINGS" }),227      ]);228      const entriesResponse = entriesRes as {229        ok: boolean;230        data?: VaultEntry[];231        error?: string;232      };233      const settingsResponse = settingsRes as {234        ok: boolean;235        data?: Record<string, unknown>;236        error?: string;237      };238      if (!entriesResponse.ok) {239        throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");240      }241      if (!settingsResponse.ok) {242        throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");243      }244245      const entries = entriesResponse.data ?? [];246      if (entries.length === 0) {247        throw new Error("EMPTY_VAULT_EXPORT");248      }249250      const settings = settingsResponse.data ?? {};251252      setProgress("deriving-key");253      await new Promise((r) => setTimeout(r, 0));254255      const result = await exportToCJK({256        entries,257        settings: settings as any,258        passphrase: passphrase(),259        argon2: ARGON2_DEFAULT,260      });261262      setProgress("encoding");263      await new Promise((r) => setTimeout(r, 0));264265      setPhraseText(result.phrase);266      setEntryCount(result.entryCount);267      setCjkCharCount(result.cjkCharCount);268      setLoginCount(result.loginCount);269      setNoteCount(result.noteCount);270      setPasskeyCount(result.passkeyCount);271      setTrashedCount(result.trashedCount);272      setProgress("done");273      setScreen("phrase");274    } catch (err) {275      const message = err instanceof Error ? err.message : String(err);276      if (isLockRecoveryError(message)) {277        props.onRequiresUnlock?.();278        return;279      }280      setPipelineError(getPipelineErrorCopy(message));281      setScreen("passphrase");282      setProgress("idle");283    }284  }285286  // -------------------------------------------------------------------------287  // Phrase-screen actions288  // -------------------------------------------------------------------------289290  async function handleCopy() {291    try {292      await navigator.clipboard.writeText(phraseText());293      setCopied(true);294      setTimeout(() => setCopied(false), 2000);295    } catch {296      // Clipboard blocked — fall back to a manual selection prompt.297      setCopied(false);298    }299  }300301  function handleSaveFile() {302    const dateStamp = new Date().toISOString().split("T")[0];303    const filename = `peach-backup-${dateStamp}.txt`;304    const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });305    const url = URL.createObjectURL(blob);306    const a = document.createElement("a");307    a.href = url;308    a.download = filename;309    a.click();310    URL.revokeObjectURL(url);311  }312313  async function handleShowQr() {314    const byteLen = new TextEncoder().encode(phraseText()).length;315    if (byteLen > 2800) return;316    setShowQrOverlay(true);317    const { default: QRCode } = await import("qrcode");318    const dataUrl = await QRCode.toDataURL(phraseText(), {319      errorCorrectionLevel: "L",320      width: 320,321      margin: 2,322      color: { dark: "#E8A87C", light: "#00000000" },323    });324    setQrDataUrl(dataUrl);325  }326327  function handleCloseQr() {328    setShowQrOverlay(false);329    setQrDataUrl(null);330  }331332  function handleDone() {333    setPassphrase("");334    setConfirmPhrase("");335    setPhraseText("");336    setShowQrOverlay(false);337    setQrDataUrl(null);338    props.onBack();339  }340341  // -------------------------------------------------------------------------342  // Render343  // -------------------------------------------------------------------------344345  return (346    <div class="peach-overlay-view ov-enter-right">347      <div class="peach-overlay-header">348        <button349          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"350          onClick={screen() === "phrase" ? handleDone : props.onBack}351          aria-label="Back"352          disabled={screen() === "generating"}353        >354          <svg355            width="7"356            height="12"357            viewBox="0 0 7 12"358            fill="none"359            stroke="currentColor"360            stroke-width="1.1"361            stroke-linecap="round"362            stroke-linejoin="round"363          >364            <path d="M6 1L1 6L6 11" />365          </svg>366        </button>367        <span class="peach-overlay-title">368          {screen() === "phrase" ? "backup phrase" : "export"}369        </span>370        <div class="peach-overlay-spacer"></div>371      </div>372373      <div class="peach-settings-body">374        <Show when={screen() === "passphrase"}>375          <PassphraseScreen376            passphrase={passphrase()}377            onPassphraseInput={handlePassphraseInput}378            confirm={confirmPhrase()}379            onConfirmInput={setConfirmPhrase}380            strengthTier={strength().tier}381            strengthScore={strength().score}382            strengthLabel={strength().feedback}383            hibp={hibp()}384            hibpChecking={hibpChecking()}385            error={liveValidation().error}386            canExport={canExport()}387            onGenerate={handleGenerate}388            onExport={() => void runExport()}389            pipelineError={pipelineError()}390          />391        </Show>392393        <Show when={screen() === "generating"}>394          <GeneratingScreen stage={progress()} />395        </Show>396397        <Show when={screen() === "phrase"}>398          <PhraseScreen399            phrase={phraseText()}400            entryCount={entryCount()}401            cjkCharCount={cjkCharCount()}402            loginCount={loginCount()}403            noteCount={noteCount()}404            passkeyCount={passkeyCount()}405            trashedCount={trashedCount()}406            copied={copied()}407            onCopy={() => void handleCopy()}408            onSaveFile={handleSaveFile}409            onDone={handleDone}410            onShowQr={() => void handleShowQr()}411            showQrOverlay={showQrOverlay()}412            qrDataUrl={qrDataUrl()}413            onCloseQr={handleCloseQr}414          />415        </Show>416      </div>417    </div>418  );419}420421// ---------------------------------------------------------------------------422// Screen 1: passphrase entry423// ---------------------------------------------------------------------------424425interface PassphraseScreenProps {426  passphrase: string;427  onPassphraseInput: (value: string) => void;428  confirm: string;429  onConfirmInput: (value: string) => void;430  strengthTier: StrengthTier;431  strengthScore: number;432  strengthLabel: string;433  hibp: HibpResult | null;434  hibpChecking: boolean;435  error?: ExportPassphraseErrorCode;436  canExport: boolean;437  onGenerate: () => void;438  onExport: () => void;439  pipelineError: string | null;440}441442function PassphraseScreen(props: PassphraseScreenProps) {443  const barFillPercent = () => {444    if (props.passphrase.length === 0) return 0;445    return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;446  };447448  const errorMessage = () =>449    props.error && props.passphrase.length > 0450      ? EXPORT_ERROR_COPY[props.error]451      : null;452453  const confirmMatches = () =>454    props.confirm.length > 0 && props.confirm === props.passphrase;455456  return (457    <>458      <div class="peach-settings-group">459        <p class="peach-settings-label">choose a passphrase</p>460        <p class="peach-settings-copy peach-settings-copy--spacious">461          Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.462        </p>463464        <div class="peach-settings-actions" style="margin-bottom:8px">465          <button466            class="peach-btn peach-btn--ghost peach-btn--sm"467            onClick={props.onGenerate}468          >469            ↻ generate one for me470          </button>471        </div>472473        <input474          class="peach-settings-input"475          type="text"476          placeholder="passphrase"477          value={props.passphrase}478          onInput={(e) =>479            props.onPassphraseInput((e.target as HTMLInputElement).value)480          }481        />482483        <Show when={props.passphrase.length > 0}>484          <div class="peach-strength-row">485            <div class="peach-strength-track">486              <div487                class="peach-strength-fill"488                style={{489                  width: `${barFillPercent()}%`,490                  background: STRENGTH_COLOUR[props.strengthTier],491                }}492              />493            </div>494            <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>495              {STRENGTH_LABEL[props.strengthTier]}496            </span>497          </div>498        </Show>499500        <Show when={props.passphrase.length > 0 && !confirmMatches()}>501          <input502            class="peach-settings-input"503            type="text"504            placeholder="confirm passphrase"505            value={props.confirm}506            onInput={(e) =>507              props.onConfirmInput((e.target as HTMLInputElement).value)508            }509          />510        </Show>511512        <Show when={confirmMatches()}>513          <div class="peach-inline-feedback peach-inline-feedback--ok">514            ✓ passphrases match515          </div>516        </Show>517518        <Show when={errorMessage()}>519          <div class="peach-inline-feedback peach-inline-feedback--error">520            {errorMessage()}521          </div>522        </Show>523524        <Show when={props.hibpChecking}>525          <div class="peach-inline-feedback peach-inline-feedback--muted">526            Checking passphrase…527          </div>528        </Show>529        <Show when={props.hibp && !props.hibp.apiReachable}>530          <div class="peach-inline-feedback peach-inline-feedback--muted">531            Couldn't reach breach database — proceed with care.532          </div>533        </Show>534535        <Show when={props.pipelineError}>536          <div class="peach-inline-feedback peach-inline-feedback--error">537            {props.pipelineError}538          </div>539        </Show>540541        <div class="peach-settings-actions" style="margin-top:10px">542          <button543            class="peach-btn peach-btn--subtle peach-btn--sm"544            onClick={props.onExport}545            disabled={!props.canExport}546          >547            Create backup phrase →548          </button>549        </div>550      </div>551    </>552  );553}554555// ---------------------------------------------------------------------------556// Screen 2: generating557// ---------------------------------------------------------------------------558559interface GeneratingScreenProps {560  stage: ProgressStage;561}562563function GeneratingScreen(props: GeneratingScreenProps) {564  return (565    <div class="peach-settings-group peach-progress-screen">566      <div class="peach-progress-screen-title">Creating your backup phrase…</div>567      <div class="peach-spinner" aria-label="Working" />568      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>569    </div>570  );571}572573// ---------------------------------------------------------------------------574// Screen 3: phrase display575// ---------------------------------------------------------------------------576577interface PhraseScreenProps {578  phrase: string;579  entryCount: number;580  cjkCharCount: number;581  loginCount: number;582  noteCount: number;583  passkeyCount: number;584  trashedCount: number;585  copied: boolean;586  onCopy: () => void;587  onSaveFile: () => void;588  onDone: () => void;589  onShowQr: () => void;590  showQrOverlay: boolean;591  qrDataUrl: string | null;592  onCloseQr: () => void;593}594595function PhraseScreen(props: PhraseScreenProps) {596  const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;597  const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;598599  return (600    <>601      <div class="peach-settings-group">602        <p class="peach-settings-label">your backup phrase</p>603        <p class="peach-settings-copy peach-settings-copy--spacious">604          Copy this phrase and store it somewhere safe. You need this phrase605          and your passphrase to restore your vault.606        </p>607608        <div class="peach-cjk-phrase-box">{props.phrase}</div>609610        <div class="peach-inline-feedback peach-inline-feedback--muted">611          {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}612          {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}613          {" · "}{props.cjkCharCount.toLocaleString()} characters614        </div>615        <div class="peach-inline-feedback peach-inline-feedback--muted">616          {props.loginCount} login{props.loginCount === 1 ? "" : "s"}617          {props.noteCount > 0618            ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`619            : ""}620          {props.passkeyCount > 0621            ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`622            : ""}623          {" · settings included"}624        </div>625626        <div class="peach-settings-actions">627          <button628            class="peach-btn peach-btn--subtle peach-btn--sm"629            onClick={props.onCopy}630          >631            {props.copied ? "Copied ✓" : "Copy phrase"}632          </button>633          <button634            class="peach-btn peach-btn--ghost peach-btn--sm"635            onClick={props.onSaveFile}636          >637            Save as file638          </button>639        </div>640641        <Show when={phraseTooLongForQr()}>642          <div class="peach-settings-copy" style="margin-top:12px">643            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.644          </div>645        </Show>646        <Show when={!phraseTooLongForQr()}>647          <div class="peach-settings-stack" style="margin-top:12px">648            <button649              class="peach-btn peach-btn--ghost peach-btn--sm"650              onClick={props.onShowQr}651            >652              Show as QR653            </button>654          </div>655        </Show>656657        <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">658          <button659            class="peach-btn peach-btn--ghost peach-btn--sm"660            onClick={props.onDone}661          >662            Done663          </button>664        </div>665      </div>666667      <Show when={props.showQrOverlay && props.qrDataUrl}>668        <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>669          <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>670            <div class="peach-overlay-header">671              <span class="peach-overlay-title">scan with Peach</span>672              <div class="peach-overlay-spacer" />673              <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">674                <CloseGlyph />675              </button>676            </div>677            <div class="peach-cjk-qr-body">678              <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>679              <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />680            </div>681          </div>682        </div>683      </Show>684    </>685  );686}687688function CloseGlyph() {689  return (690    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">691      <line x1="2" y1="2" x2="10" y2="10" />692      <line x1="10" y1="2" x2="2" y2="10" />693    </svg>694  );695}