// Signal-conditioned views in this file must use , never a // signal-conditioned early return (`if (signal()) return `). Early // returns in SolidJS run once at mount and never re-execute when signals // change — see Projects.md §4.1. import { createMemo, createSignal, Show } from "solid-js"; import { sendMessage } from "../App"; import { buildPayload } from "../../crypto/peachPayload"; import { encodePeachScript } from "../../crypto/peachScript"; import { RuneCanvas } from "../components/RuneCanvas"; import type { PeachLicenseExport, VaultEntry } from "../../shared/types"; type ExportSubview = "menu" | "peachscript" | "csv"; interface ExportProps { onBack: () => void; } // Lazy-load @zxcvbn-ts — it's heavy (~700 kB) and only needed when the user // starts typing a backup password. Shared module-level cache so it is only // fetched once across multiple PeachScriptExport mounts. Replaces the // unmaintained `zxcvbn` package (LV-8; see Lock.tsx). type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } }; let _zxcvbn: ZxcvbnFn | null = null; async function getZxcvbn(): Promise { if (_zxcvbn) return _zxcvbn; const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([ import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]); zxcvbnOptions.setOptions({ translations: en.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...en.dictionary }, }); _zxcvbn = (pw: string) => zxcvbn(pw); return _zxcvbn; } // Backup password must score at least 3 (strong) on the zxcvbn scale (0–4). // This prevents a targeted wordlist attack from cracking a weak backup in // seconds even though Argon2id raises the per-guess cost. const STRENGTH_REQUIRED = 3; export function ExportView(props: ExportProps) { const [view, setView] = createSignal("menu"); return (
setView(v)} /> setView("menu")} /> setView("menu")} />
); } // --------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------- function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) { return ( <>

peachscript

Your vault as a string of Peach symbols you can paste into Peach on another device. Designed for Peach-to-Peach transfer.

CSV (leaving peach)

Plain-text export for moving into another password manager. Anyone with the file can read your passwords.

); } // --------------------------------------------------------------------------- // PeachScript // --------------------------------------------------------------------------- function PeachScriptExport(props: { onBack: () => void }) { const [pass, setPass] = createSignal(""); const [confirm, setConfirm] = createSignal(""); const [strengthScore, setStrengthScore] = createSignal(0); const [strengthHint, setStrengthHint] = createSignal(""); const [working, setWorking] = createSignal(false); const [script, setScript] = createSignal(null); const [error, setError] = createSignal(null); const [entryCount, setEntryCount] = createSignal(0); const [stats, setStats] = createSignal<{ cards: number; passkeys: number; notes: number; logins: number; }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 }); const [copied, setCopied] = createSignal(false); const passReady = createMemo( () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm() ); function handlePassInput(value: string) { setPass(value); if (!value) { setStrengthScore(0); setStrengthHint(""); return; } // Fire-and-forget: if zxcvbn hasn't loaded yet the signal updates when it does void getZxcvbn().then((zxcvbn) => { if (pass() !== value) return; // stale — user typed ahead const result = zxcvbn(value); setStrengthScore(result.score); const hint = result.feedback.warning || result.feedback.suggestions[0] || ""; setStrengthHint(hint); }); } async function handleEncode() { setError(null); setScript(null); setCopied(false); if (!passReady()) { setError("Backup passwords don't match or are too short."); return; } setWorking(true); try { const env = await assembleEnvelope(pass()); setEntryCount(env.entryCount); setStats(env.stats); // PB-26-113: the ceiling is enforced by the encoder, which measures the // encoded file's UTF-8 bytes rather than the envelope's — encoding three // bytes per envelope byte is what makes the two differ. setScript(encodePeachScript(env.bytes)); } catch (err) { setError( err instanceof Error && err.message === "PEACHSCRIPT_TOO_LARGE" ? "This vault is too large for a PeachScript backup — use Codex export instead." : "Couldn't create PeachScript backup.", ); } finally { setWorking(false); } } async function handleCopy() { const s = script(); if (!s) return; try { await navigator.clipboard.writeText(s); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { setError(err instanceof Error ? err.message : "Couldn't copy to clipboard."); } } function handleDownload() { const s = script(); if (!s) return; // .peach file = the same PeachScript text, in a single file. The PUA // codepoints render as runes only when paired with PeachScript-Regular.otf; // outside Peach the file looks like opaque bytes, which is the point. // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach, // hyphens only, lowercase extension, date-only (not time-of-day). const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" }); const url = URL.createObjectURL(blob); const date = new Date().toISOString().slice(0, 10); const a = document.createElement("a"); a.href = url; a.download = `peach-backup-${date}.peach`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 5000); } return ( <>

backup password

Choose a password to lock this PeachScript. You'll need it to restore on another device.

queueMicrotask(() => el.focus())} class="peach-settings-input" type="password" placeholder="backup password" value={pass()} onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter" && passReady() && !working()) { e.preventDefault(); void handleEncode(); } }} /> 0}>

{strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}

= STRENGTH_REQUIRED && strengthHint()}>

{strengthHint()}

setConfirm((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter" && passReady() && !working()) { e.preventDefault(); void handleEncode(); } }} /> 0 && pass() !== confirm()}>

Passwords don't match

} >

peachscript

{entryCount()} entr{entryCount() === 1 ? "y" : "ies"} {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""} {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""} {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}

Paste the script — or import the .peach file — into Peach on your other device to restore your vault. Won't work anywhere else.

{error()}
); } // --------------------------------------------------------------------------- // CSV — non-skippable warning, every time, by design (spec §5.2). // --------------------------------------------------------------------------- function CSVExport(props: { onBack: () => void }) { const [acknowledged, setAcknowledged] = createSignal(false); const [masterPassword, setMasterPassword] = createSignal(""); const [working, setWorking] = createSignal(false); const [error, setError] = createSignal(null); const [done, setDone] = createSignal(null); async function handleExport() { setError(null); setDone(null); setWorking(true); try { const res = await sendMessage({ type: "EXPORT_ENTRIES", payload: { password: masterPassword() } }); const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? []; // Passkeys are device-bound credentials and cannot be moved between // devices via export. Strip the passkey sub-object out of any login // entry that carries one; the entry itself still exports if it has a // password too. Passkey-only entries (no password) are skipped entirely. const active = entries .filter((e) => e.deletedAt === undefined) .filter((e) => !(e.passkey && !e.password)) .map((e) => (e.passkey ? { ...e, passkey: undefined } : e)); const excludedPasskeys = entries.filter( (e) => e.deletedAt === undefined && e.passkey ).length; if (active.length === 0) { setError("Your vault has no entries to export."); return; } const csv = buildCsv(active); const filename = `peach-export-${dateStamp()}.csv`; downloadBlob(csv, "text/csv", filename); const suffix = excludedPasskeys > 0 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.` : ""; setDone( `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}` ); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setWorking(false); } } return ( <>
This export is unencrypted. Anyone who gets this file can read your passwords. Only use this to move to another password manager — delete it once the import is done.
setMasterPassword((e.target as HTMLInputElement).value)} placeholder="enter your master password" />
{error()}
✓ {done()}
); } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function OverlayHeader(props: { title: string; onBack: () => void }) { return (
{props.title}
); } interface AssembledEnvelope { bytes: Uint8Array; entryCount: number; stats: { cards: number; passkeys: number; notes: number; logins: number }; } async function assembleEnvelope(passphrase: string): Promise { const entriesRes = await sendMessage({ type: "GET_ENTRIES" }); const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? []; const settingsRes = await sendMessage({ type: "GET_SETTINGS" }); const settings = (settingsRes as { ok: boolean; data?: Record }).data ?? {}; let license: PeachLicenseExport | undefined; try { const info = await sendMessage({ type: "GET_LICENSE" }); const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data; if (data?.valid && data.plan === "pro") { const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" }); const keyStr = (keyRes as { ok: boolean; data?: string }).data; if (keyStr) { license = { key: keyStr, ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}), }; } } } catch { // License absent — proceed without it. } const stats = countTypes(entries); const bytes = await buildPayload({ entries, settings: settings as Parameters[0]["settings"], license, passphrase, }); return { bytes, entryCount: entries.length, stats }; } function countTypes(entries: VaultEntry[]): { cards: number; passkeys: number; notes: number; logins: number; } { let cards = 0; let passkeys = 0; let notes = 0; let logins = 0; for (const e of entries) { if (e.deletedAt) continue; if (e.passkey) { passkeys++; // A login that ALSO has a password counts toward the login total too — // exporting it preserves the password (the passkey gets stripped). if (e.type === "login" && e.password) logins++; continue; } if (e.type === "card") cards++; else if (e.type === "note") notes++; else logins++; } return { cards, passkeys, notes, logins }; } // Neutralize spreadsheet formula injection (OWASP): Excel/Sheets/Calc execute // a cell that begins with = + - @ or a leading tab/CR as a formula, regardless // of CSV quoting. Vault fields can be attacker-influenced (a malicious site can // seed an entry username/notes), so a crafted =HYPERLINK/WEBSERVICE/DDE payload // in the exported CSV could exfiltrate other plaintext cells on open. Prefix // any such field with a single quote so the cell is treated as text (audit MEDIUM). export function csvSafe(value: string): string { return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value; } function buildCsv(entries: VaultEntry[]): string { const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"]; const rows = entries.map((e) => { const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login"; const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? ""; const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? ""; return [ e.title, e.url ?? "", username, password, e.notes ?? "", type, Array.isArray(e.tags) ? e.tags.join("|") : "", e.totp ?? "", ] .map((v) => `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"` ) .join(","); }); return [headers.join(","), ...rows].join("\n"); } function dateStamp(): string { return new Date().toISOString().split("T")[0]; } function downloadBlob(content: string, mimeType: string, filename: string): void { const url = URL.createObjectURL(new Blob([content], { type: mimeType })); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }