CJKExport.tsx

23.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 { PeachLicenseExport, 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, licenseRes] = await Promise.all([225        sendMessage({ type: "GET_ENTRIES" }),226        sendMessage({ type: "GET_SETTINGS" }),227        sendMessage({ type: "GET_LICENSE" }),228      ]);229      const entriesResponse = entriesRes as {230        ok: boolean;231        data?: VaultEntry[];232        error?: string;233      };234      const settingsResponse = settingsRes as {235        ok: boolean;236        data?: Record<string, unknown>;237        error?: string;238      };239      if (!entriesResponse.ok) {240        throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");241      }242      if (!settingsResponse.ok) {243        throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");244      }245246      const entries = entriesResponse.data ?? [];247      if (entries.length === 0) {248        throw new Error("EMPTY_VAULT_EXPORT");249      }250251      const settings = settingsResponse.data ?? {};252253      // Build license export if the vault is Pro.254      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;255      let license: PeachLicenseExport | undefined;256      if (licenseInfo?.valid && licenseInfo.plan === "pro") {257        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });258        const keyStr = (keyRes as { ok: boolean; data?: string }).data;259        if (keyStr) {260          license = {261            key: keyStr,262            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),263          };264        }265      }266267      setProgress("deriving-key");268      await new Promise((r) => setTimeout(r, 0));269270      const result = await exportToCJK({271        entries,272        settings: settings as any,273        license,274        passphrase: passphrase(),275        argon2: ARGON2_DEFAULT,276      });277278      setProgress("encoding");279      await new Promise((r) => setTimeout(r, 0));280281      setPhraseText(result.phrase);282      setEntryCount(result.entryCount);283      setCjkCharCount(result.cjkCharCount);284      setLoginCount(result.loginCount);285      setNoteCount(result.noteCount);286      setPasskeyCount(result.passkeyCount);287      setTrashedCount(result.trashedCount);288      setProgress("done");289      setScreen("phrase");290    } catch (err) {291      const message = err instanceof Error ? err.message : String(err);292      if (isLockRecoveryError(message)) {293        props.onRequiresUnlock?.();294        return;295      }296      setPipelineError(getPipelineErrorCopy(message));297      setScreen("passphrase");298      setProgress("idle");299    }300  }301302  // -------------------------------------------------------------------------303  // Phrase-screen actions304  // -------------------------------------------------------------------------305306  async function handleCopy() {307    try {308      await navigator.clipboard.writeText(phraseText());309      setCopied(true);310      setTimeout(() => setCopied(false), 2000);311    } catch {312      // Clipboard blocked — fall back to a manual selection prompt.313      setCopied(false);314    }315  }316317  function handleSaveFile() {318    const dateStamp = new Date().toISOString().split("T")[0];319    const filename = `peach-backup-${dateStamp}.txt`;320    const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });321    const url = URL.createObjectURL(blob);322    const a = document.createElement("a");323    a.href = url;324    a.download = filename;325    a.click();326    URL.revokeObjectURL(url);327  }328329  async function handleShowQr() {330    const byteLen = new TextEncoder().encode(phraseText()).length;331    if (byteLen > 2800) return;332    setShowQrOverlay(true);333    const { default: QRCode } = await import("qrcode");334    const dataUrl = await QRCode.toDataURL(phraseText(), {335      errorCorrectionLevel: "L",336      width: 320,337      margin: 2,338      color: { dark: "#E8A87C", light: "#00000000" },339    });340    setQrDataUrl(dataUrl);341  }342343  function handleCloseQr() {344    setShowQrOverlay(false);345    setQrDataUrl(null);346  }347348  function handleDone() {349    setPassphrase("");350    setConfirmPhrase("");351    setPhraseText("");352    setShowQrOverlay(false);353    setQrDataUrl(null);354    props.onBack();355  }356357  // -------------------------------------------------------------------------358  // Render359  // -------------------------------------------------------------------------360361  return (362    <div class="peach-overlay-view ov-enter-right">363      <div class="peach-overlay-header">364        <button365          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"366          onClick={screen() === "phrase" ? handleDone : props.onBack}367          aria-label="Back"368          disabled={screen() === "generating"}369        >370          <svg371            width="7"372            height="12"373            viewBox="0 0 7 12"374            fill="none"375            stroke="currentColor"376            stroke-width="1.1"377            stroke-linecap="round"378            stroke-linejoin="round"379          >380            <path d="M6 1L1 6L6 11" />381          </svg>382        </button>383        <span class="peach-overlay-title">384          {screen() === "phrase" ? "backup phrase" : "export"}385        </span>386        <div class="peach-overlay-spacer"></div>387      </div>388389      <div class="peach-settings-body">390        <Show when={screen() === "passphrase"}>391          <PassphraseScreen392            passphrase={passphrase()}393            onPassphraseInput={handlePassphraseInput}394            confirm={confirmPhrase()}395            onConfirmInput={setConfirmPhrase}396            strengthTier={strength().tier}397            strengthScore={strength().score}398            strengthLabel={strength().feedback}399            hibp={hibp()}400            hibpChecking={hibpChecking()}401            error={liveValidation().error}402            canExport={canExport()}403            onGenerate={handleGenerate}404            onExport={() => void runExport()}405            pipelineError={pipelineError()}406          />407        </Show>408409        <Show when={screen() === "generating"}>410          <GeneratingScreen stage={progress()} />411        </Show>412413        <Show when={screen() === "phrase"}>414          <PhraseScreen415            phrase={phraseText()}416            entryCount={entryCount()}417            cjkCharCount={cjkCharCount()}418            loginCount={loginCount()}419            noteCount={noteCount()}420            passkeyCount={passkeyCount()}421            trashedCount={trashedCount()}422            copied={copied()}423            onCopy={() => void handleCopy()}424            onSaveFile={handleSaveFile}425            onDone={handleDone}426            onShowQr={() => void handleShowQr()}427            showQrOverlay={showQrOverlay()}428            qrDataUrl={qrDataUrl()}429            onCloseQr={handleCloseQr}430          />431        </Show>432      </div>433    </div>434  );435}436437// ---------------------------------------------------------------------------438// Screen 1: passphrase entry439// ---------------------------------------------------------------------------440441interface PassphraseScreenProps {442  passphrase: string;443  onPassphraseInput: (value: string) => void;444  confirm: string;445  onConfirmInput: (value: string) => void;446  strengthTier: StrengthTier;447  strengthScore: number;448  strengthLabel: string;449  hibp: HibpResult | null;450  hibpChecking: boolean;451  error?: ExportPassphraseErrorCode;452  canExport: boolean;453  onGenerate: () => void;454  onExport: () => void;455  pipelineError: string | null;456}457458function PassphraseScreen(props: PassphraseScreenProps) {459  const barFillPercent = () => {460    if (props.passphrase.length === 0) return 0;461    return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;462  };463464  const errorMessage = () =>465    props.error && props.passphrase.length > 0466      ? EXPORT_ERROR_COPY[props.error]467      : null;468469  const confirmMatches = () =>470    props.confirm.length > 0 && props.confirm === props.passphrase;471472  return (473    <>474      <div class="peach-settings-group">475        <p class="peach-settings-label">choose a passphrase</p>476        <p class="peach-settings-copy peach-settings-copy--spacious">477          Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.478        </p>479480        <div class="peach-settings-actions" style="margin-bottom:8px">481          <button482            class="peach-btn peach-btn--ghost peach-btn--sm"483            onClick={props.onGenerate}484          >485            ↻ generate one for me486          </button>487        </div>488489        <input490          class="peach-settings-input"491          type="text"492          placeholder="passphrase"493          value={props.passphrase}494          onInput={(e) =>495            props.onPassphraseInput((e.target as HTMLInputElement).value)496          }497        />498499        <Show when={props.passphrase.length > 0}>500          <div class="peach-strength-row">501            <div class="peach-strength-track">502              <div503                class="peach-strength-fill"504                style={{505                  width: `${barFillPercent()}%`,506                  background: STRENGTH_COLOUR[props.strengthTier],507                }}508              />509            </div>510            <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>511              {STRENGTH_LABEL[props.strengthTier]}512            </span>513          </div>514        </Show>515516        <Show when={props.passphrase.length > 0 && !confirmMatches()}>517          <input518            class="peach-settings-input"519            type="text"520            placeholder="confirm passphrase"521            value={props.confirm}522            onInput={(e) =>523              props.onConfirmInput((e.target as HTMLInputElement).value)524            }525          />526        </Show>527528        <Show when={confirmMatches()}>529          <div class="peach-inline-feedback peach-inline-feedback--ok">530            ✓ passphrases match531          </div>532        </Show>533534        <Show when={errorMessage()}>535          <div class="peach-inline-feedback peach-inline-feedback--error">536            {errorMessage()}537          </div>538        </Show>539540        <Show when={props.hibpChecking}>541          <div class="peach-inline-feedback peach-inline-feedback--muted">542            Checking passphrase…543          </div>544        </Show>545        <Show when={props.hibp && !props.hibp.apiReachable}>546          <div class="peach-inline-feedback peach-inline-feedback--muted">547            Couldn't reach breach database — proceed with care.548          </div>549        </Show>550551        <Show when={props.pipelineError}>552          <div class="peach-inline-feedback peach-inline-feedback--error">553            {props.pipelineError}554          </div>555        </Show>556557        <div class="peach-settings-actions" style="margin-top:10px">558          <button559            class="peach-btn peach-btn--subtle peach-btn--sm"560            onClick={props.onExport}561            disabled={!props.canExport}562          >563            Create backup phrase →564          </button>565        </div>566      </div>567    </>568  );569}570571// ---------------------------------------------------------------------------572// Screen 2: generating573// ---------------------------------------------------------------------------574575interface GeneratingScreenProps {576  stage: ProgressStage;577}578579function GeneratingScreen(props: GeneratingScreenProps) {580  return (581    <div class="peach-settings-group peach-progress-screen">582      <div class="peach-progress-screen-title">Creating your backup phrase…</div>583      <div class="peach-spinner" aria-label="Working" />584      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>585    </div>586  );587}588589// ---------------------------------------------------------------------------590// Screen 3: phrase display591// ---------------------------------------------------------------------------592593interface PhraseScreenProps {594  phrase: string;595  entryCount: number;596  cjkCharCount: number;597  loginCount: number;598  noteCount: number;599  passkeyCount: number;600  trashedCount: number;601  copied: boolean;602  onCopy: () => void;603  onSaveFile: () => void;604  onDone: () => void;605  onShowQr: () => void;606  showQrOverlay: boolean;607  qrDataUrl: string | null;608  onCloseQr: () => void;609}610611function PhraseScreen(props: PhraseScreenProps) {612  const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;613  const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;614615  return (616    <>617      <div class="peach-settings-group">618        <p class="peach-settings-label">your backup phrase</p>619        <p class="peach-settings-copy peach-settings-copy--spacious">620          Copy this phrase and store it somewhere safe. You need this phrase621          and your passphrase to restore your vault.622        </p>623624        <div class="peach-cjk-phrase-box">{props.phrase}</div>625626        <div class="peach-inline-feedback peach-inline-feedback--muted">627          {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}628          {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}629          {" · "}{props.cjkCharCount.toLocaleString()} characters630        </div>631        <div class="peach-inline-feedback peach-inline-feedback--muted">632          {props.loginCount} login{props.loginCount === 1 ? "" : "s"}633          {props.noteCount > 0634            ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`635            : ""}636          {props.passkeyCount > 0637            ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`638            : ""}639          {" · settings included"}640        </div>641642        <div class="peach-settings-actions">643          <button644            class="peach-btn peach-btn--subtle peach-btn--sm"645            onClick={props.onCopy}646          >647            {props.copied ? "Copied ✓" : "Copy phrase"}648          </button>649          <button650            class="peach-btn peach-btn--ghost peach-btn--sm"651            onClick={props.onSaveFile}652          >653            Save as file654          </button>655        </div>656657        <Show when={phraseTooLongForQr()}>658          <div class="peach-settings-copy" style="margin-top:12px">659            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.660          </div>661        </Show>662        <Show when={!phraseTooLongForQr()}>663          <div class="peach-settings-stack" style="margin-top:12px">664            <button665              class="peach-btn peach-btn--ghost peach-btn--sm"666              onClick={props.onShowQr}667            >668              Show as QR669            </button>670          </div>671        </Show>672673        <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">674          <button675            class="peach-btn peach-btn--ghost peach-btn--sm"676            onClick={props.onDone}677          >678            Done679          </button>680        </div>681      </div>682683      <Show when={props.showQrOverlay && props.qrDataUrl}>684        <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>685          <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>686            <div class="peach-overlay-header">687              <span class="peach-overlay-title">scan with Peach</span>688              <div class="peach-overlay-spacer" />689              <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">690                <CloseGlyph />691              </button>692            </div>693            <div class="peach-cjk-qr-body">694              <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>695              <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />696            </div>697          </div>698        </div>699      </Show>700    </>701  );702}703704function CloseGlyph() {705  return (706    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">707      <line x1="2" y1="2" x2="10" y2="10" />708      <line x1="10" y1="2" x2="2" y2="10" />709    </svg>710  );711}