/** * CJK phrase export flow (spec §7). * * Three screens: * 1. passphrase entry + strength + confirm + high-security + generate * 2. generating (progress labels as each pipeline step runs) * 3. phrase display (copy / save-as-file / done) * * The crypto pipeline (`exportToCJK`) lives in src/crypto. This component * is purely UX — it drives the pipeline and reports progress. A * passphrase validation error should never appear on screen 2 or 3 since * the Export button is disabled until all §6.1 requirements pass. */ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; import { ARGON2_DEFAULT, exportToCJK, } from "../../crypto/cjkExport"; import { checkPassphraseBreached, evaluateStrength, generateDicewarePassphrase, MIN_PASSPHRASE_LENGTH, validateForExport, type ExportPassphraseErrorCode, type HibpResult, type StrengthTier, } from "../../crypto/cjkPassphrase"; import type { PeachLicenseExport, VaultEntry } from "../../shared/types"; import { sendMessage } from "../App"; // --------------------------------------------------------------------------- // Error code → user-facing copy (spec §9) // --------------------------------------------------------------------------- const EXPORT_ERROR_COPY: Record = { PASSPHRASE_TOO_SHORT: "Your export passphrase must be at least 16 characters.", PASSPHRASE_TOO_WEAK: "Your export passphrase is too easy to guess. Try a random phrase of 5–6 words.", PASSPHRASE_BREACHED: "This passphrase has appeared in a known data breach. Choose a different one.", PASSPHRASE_MISMATCH: "Passphrases don't match. Re-enter them carefully.", }; const STRENGTH_COLOUR: Record = { weak: "#DC503C", fair: "#D96830", good: "#64D264", excellent: "#5090D8", }; const STRENGTH_LABEL: Record = { weak: "weak", fair: "fair", good: "good", excellent: "excellent", }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- type Screen = "passphrase" | "generating" | "phrase"; type ProgressStage = | "idle" | "deriving-key" | "encrypting" | "encoding" | "done"; const PROGRESS_COPY: Record = { idle: "", "deriving-key": "Deriving key…", encrypting: "Encrypting…", encoding: "Encoding…", done: "Done.", }; interface CJKExportProps { onBack: () => void; onRequiresUnlock?: () => void; } function getPipelineErrorCopy(error: string | undefined): string { if (!error) return "Export failed."; if (error === "EMPTY_VAULT_EXPORT") { return "Your vault has no entries to export."; } if (error === "vault locked" || error === "VAULT_LOCKED") { return "Peach locked before the export could start. Unlock it again and retry."; } if (error === "SERVICE_WORKER_UNAVAILABLE") { return "Peach couldn't reach its background worker. Reopen the popup and try again."; } return `Export failed: ${error}`; } function isLockRecoveryError(error: string | undefined): boolean { return ( error === "vault locked" || error === "VAULT_LOCKED" || error === "SERVICE_WORKER_UNAVAILABLE" ); } export function CJKExportView(props: CJKExportProps) { const [screen, setScreen] = createSignal("passphrase"); const [passphrase, setPassphrase] = createSignal(""); const [confirmPhrase, setConfirmPhrase] = createSignal(""); const [hibp, setHibp] = createSignal(null); const [hibpChecking, setHibpChecking] = createSignal(false); let hibpController: AbortController | null = null; let hibpDebounce: ReturnType | null = null; const [progress, setProgress] = createSignal("idle"); const [phraseText, setPhraseText] = createSignal(""); const [entryCount, setEntryCount] = createSignal(0); const [cjkCharCount, setCjkCharCount] = createSignal(0); const [passkeyCount, setPasskeyCount] = createSignal(0); const [noteCount, setNoteCount] = createSignal(0); const [loginCount, setLoginCount] = createSignal(0); const [trashedCount, setTrashedCount] = createSignal(0); const [copied, setCopied] = createSignal(false); const [pipelineError, setPipelineError] = createSignal(null); const [showQrOverlay, setShowQrOverlay] = createSignal(false); const [qrDataUrl, setQrDataUrl] = createSignal(null); createEffect(() => { if (!showQrOverlay()) return; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") handleCloseQr(); }; window.addEventListener("keydown", handler); onCleanup(() => window.removeEventListener("keydown", handler)); }); // ------------------------------------------------------------------------- // Strength + validation memo — recomputed on every input // ------------------------------------------------------------------------- const strength = createMemo(() => evaluateStrength(passphrase())); const liveValidation = createMemo(() => validateForExport({ passphrase: passphrase(), confirm: confirmPhrase(), hibp: hibp() ?? undefined, }) ); const canExport = createMemo(() => liveValidation().ok); // ------------------------------------------------------------------------- // HIBP: debounced on passphrase change (spec §6.3 non-blocking) // ------------------------------------------------------------------------- function schedulePwnedCheck(value: string) { if (hibpController) hibpController.abort(); if (hibpDebounce) clearTimeout(hibpDebounce); setHibp(null); setHibpChecking(false); if (value.length < MIN_PASSPHRASE_LENGTH) return; // don't burn HIBP requests on tiny inputs hibpDebounce = setTimeout(() => { const controller = new AbortController(); hibpController = controller; setHibpChecking(true); checkPassphraseBreached(value, controller.signal) .then((result) => { // Only apply if the input hasn't changed underneath us. if (passphrase() === value) { setHibp(result); } }) .catch(() => { // AbortError — user kept typing. Silent. }) .finally(() => { if (hibpController === controller) { setHibpChecking(false); } }); }, 500); } onCleanup(() => { if (hibpController) hibpController.abort(); if (hibpDebounce) clearTimeout(hibpDebounce); }); function handlePassphraseInput(value: string) { setPassphrase(value); setHibp(null); schedulePwnedCheck(value); } function handleGenerate() { const generated = generateDicewarePassphrase(6); setPassphrase(generated); setConfirmPhrase(generated); // auto-confirm so user only needs to copy it schedulePwnedCheck(generated); } // ------------------------------------------------------------------------- // Export pipeline driver // ------------------------------------------------------------------------- async function runExport() { if (!canExport()) return; setScreen("generating"); setProgress("deriving-key"); setPipelineError(null); try { // 1. Fetch entries + settings from the background vault const [entriesRes, settingsRes, licenseRes] = await Promise.all([ sendMessage({ type: "GET_ENTRIES" }), sendMessage({ type: "GET_SETTINGS" }), sendMessage({ type: "GET_LICENSE" }), ]); const entriesResponse = entriesRes as { ok: boolean; data?: VaultEntry[]; error?: string; }; const settingsResponse = settingsRes as { ok: boolean; data?: Record; error?: string; }; if (!entriesResponse.ok) { throw new Error(entriesResponse.error ?? "Couldn't read vault entries."); } if (!settingsResponse.ok) { throw new Error(settingsResponse.error ?? "Couldn't read vault settings."); } const entries = entriesResponse.data ?? []; if (entries.length === 0) { throw new Error("EMPTY_VAULT_EXPORT"); } const settings = settingsResponse.data ?? {}; // Build license export if the vault is Pro. const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data; let license: PeachLicenseExport | undefined; if (licenseInfo?.valid && licenseInfo.plan === "pro") { const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" }); const keyStr = (keyRes as { ok: boolean; data?: string }).data; if (keyStr) { license = { key: keyStr, ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}), }; } } setProgress("deriving-key"); await new Promise((r) => setTimeout(r, 0)); const result = await exportToCJK({ entries, settings: settings as any, license, passphrase: passphrase(), argon2: ARGON2_DEFAULT, }); setProgress("encoding"); await new Promise((r) => setTimeout(r, 0)); setPhraseText(result.phrase); setEntryCount(result.entryCount); setCjkCharCount(result.cjkCharCount); setLoginCount(result.loginCount); setNoteCount(result.noteCount); setPasskeyCount(result.passkeyCount); setTrashedCount(result.trashedCount); setProgress("done"); setScreen("phrase"); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (isLockRecoveryError(message)) { props.onRequiresUnlock?.(); return; } setPipelineError(getPipelineErrorCopy(message)); setScreen("passphrase"); setProgress("idle"); } } // ------------------------------------------------------------------------- // Phrase-screen actions // ------------------------------------------------------------------------- async function handleCopy() { try { await navigator.clipboard.writeText(phraseText()); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Clipboard blocked — fall back to a manual selection prompt. setCopied(false); } } function handleSaveFile() { const dateStamp = new Date().toISOString().split("T")[0]; const filename = `peach-backup-${dateStamp}.txt`; const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function handleShowQr() { const byteLen = new TextEncoder().encode(phraseText()).length; if (byteLen > 2800) return; setShowQrOverlay(true); const { default: QRCode } = await import("qrcode"); const dataUrl = await QRCode.toDataURL(phraseText(), { errorCorrectionLevel: "L", width: 320, margin: 2, color: { dark: "#E8A87C", light: "#00000000" }, }); setQrDataUrl(dataUrl); } function handleCloseQr() { setShowQrOverlay(false); setQrDataUrl(null); } function handleDone() { setPassphrase(""); setConfirmPhrase(""); setPhraseText(""); setShowQrOverlay(false); setQrDataUrl(null); props.onBack(); } // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (
{screen() === "phrase" ? "backup phrase" : "export"}
void runExport()} pipelineError={pipelineError()} /> void handleCopy()} onSaveFile={handleSaveFile} onDone={handleDone} onShowQr={() => void handleShowQr()} showQrOverlay={showQrOverlay()} qrDataUrl={qrDataUrl()} onCloseQr={handleCloseQr} />
); } // --------------------------------------------------------------------------- // Screen 1: passphrase entry // --------------------------------------------------------------------------- interface PassphraseScreenProps { passphrase: string; onPassphraseInput: (value: string) => void; confirm: string; onConfirmInput: (value: string) => void; strengthTier: StrengthTier; strengthScore: number; strengthLabel: string; hibp: HibpResult | null; hibpChecking: boolean; error?: ExportPassphraseErrorCode; canExport: boolean; onGenerate: () => void; onExport: () => void; pipelineError: string | null; } function PassphraseScreen(props: PassphraseScreenProps) { const barFillPercent = () => { if (props.passphrase.length === 0) return 0; return [10, 25, 50, 75, 100][props.strengthScore] ?? 10; }; const errorMessage = () => props.error && props.passphrase.length > 0 ? EXPORT_ERROR_COPY[props.error] : null; const confirmMatches = () => props.confirm.length > 0 && props.confirm === props.passphrase; return ( <>

choose a passphrase

Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.

props.onPassphraseInput((e.target as HTMLInputElement).value) } /> 0}>
{STRENGTH_LABEL[props.strengthTier]}
0 && !confirmMatches()}> props.onConfirmInput((e.target as HTMLInputElement).value) } />
✓ passphrases match
{errorMessage()}
Checking passphrase…
Couldn't reach breach database — proceed with care.
{props.pipelineError}
); } // --------------------------------------------------------------------------- // Screen 2: generating // --------------------------------------------------------------------------- interface GeneratingScreenProps { stage: ProgressStage; } function GeneratingScreen(props: GeneratingScreenProps) { return (
Creating your backup phrase…
{PROGRESS_COPY[props.stage]}
); } // --------------------------------------------------------------------------- // Screen 3: phrase display // --------------------------------------------------------------------------- interface PhraseScreenProps { phrase: string; entryCount: number; cjkCharCount: number; loginCount: number; noteCount: number; passkeyCount: number; trashedCount: number; copied: boolean; onCopy: () => void; onSaveFile: () => void; onDone: () => void; onShowQr: () => void; showQrOverlay: boolean; qrDataUrl: string | null; onCloseQr: () => void; } function PhraseScreen(props: PhraseScreenProps) { const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount; const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800; return ( <>

your backup phrase

Copy this phrase and store it somewhere safe. You need this phrase and your passphrase to restore your vault.

{props.phrase}
{activeCount()} entr{activeCount() === 1 ? "y" : "ies"} {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""} {" · "}{props.cjkCharCount.toLocaleString()} characters
{props.loginCount} login{props.loginCount === 1 ? "" : "s"} {props.noteCount > 0 ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}` : ""} {props.passkeyCount > 0 ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}` : ""} {" · settings included"}
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.