Export.tsx

21.2 KB · raw

1// Signal-conditioned views in this file must use <Show>, never a2// signal-conditioned early return (`if (signal()) return <X/>`). Early3// returns in SolidJS run once at mount and never re-execute when signals4// change — see Projects.md §4.1.5import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { buildPayload } from "../../crypto/peachPayload";9import { encodePeachScript } from "../../crypto/peachScript";10import { RuneCanvas } from "../components/RuneCanvas";11import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1213type ExportSubview = "menu" | "peachscript" | "csv";1415interface ExportProps {16  onBack: () => void;17}1819// Lazy-load @zxcvbn-ts — it's heavy (~700 kB) and only needed when the user20// starts typing a backup password. Shared module-level cache so it is only21// fetched once across multiple PeachScriptExport mounts. Replaces the22// unmaintained `zxcvbn` package (LV-8; see Lock.tsx).23type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } };24let _zxcvbn: ZxcvbnFn | null = null;25async function getZxcvbn(): Promise<ZxcvbnFn> {26  if (_zxcvbn) return _zxcvbn;27  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([28    import("@zxcvbn-ts/core"),29    import("@zxcvbn-ts/language-common"),30    import("@zxcvbn-ts/language-en"),31  ]);32  zxcvbnOptions.setOptions({33    translations: en.translations,34    graphs: common.adjacencyGraphs,35    dictionary: { ...common.dictionary, ...en.dictionary },36  });37  _zxcvbn = (pw: string) => zxcvbn(pw);38  return _zxcvbn;39}4041// Backup password must score at least 3 (strong) on the zxcvbn scale (0–4).42// This prevents a targeted wordlist attack from cracking a weak backup in43// seconds even though Argon2id raises the per-guess cost.44const STRENGTH_REQUIRED = 3;4546export function ExportView(props: ExportProps) {47  const [view, setView] = createSignal<ExportSubview>("menu");48  return (49    <div class="peach-overlay-view ov-enter-right" style="display:flex">50      <Show when={view() === "menu"}>51        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />52      </Show>53      <Show when={view() === "peachscript"}>54        <PeachScriptExport onBack={() => setView("menu")} />55      </Show>56      <Show when={view() === "csv"}>57        <CSVExport onBack={() => setView("menu")} />58      </Show>59    </div>60  );61}6263// ---------------------------------------------------------------------------64// Menu65// ---------------------------------------------------------------------------6667function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {68  return (69    <>70      <OverlayHeader title="export" onBack={props.onBack} />71      <div class="peach-settings-body peach-export-menu">72        <div class="peach-settings-group peach-export-card peach-export-card--primary">73          <p class="peach-settings-label">peachscript</p>74          <p class="peach-export-card-copy">75            Your vault as a string of Peach symbols you can paste into Peach76            on another device. Designed for Peach-to-Peach transfer.77          </p>78          <button79            class="peach-btn peach-btn--subtle peach-btn--sm"80            onClick={() => props.onPick("peachscript")}81          >82            Create PeachScript →83          </button>84        </div>8586        <div class="peach-settings-group peach-export-card">87          <p class="peach-settings-label">CSV (leaving peach)</p>88          <p class="peach-export-card-copy">89            Plain-text export for moving into another password manager. Anyone90            with the file can read your passwords.91          </p>92          <button93            class="peach-btn peach-btn--ghost peach-btn--sm"94            onClick={() => props.onPick("csv")}95          >96            Export as CSV →97          </button>98        </div>99      </div>100    </>101  );102}103104// ---------------------------------------------------------------------------105// PeachScript106// ---------------------------------------------------------------------------107108function PeachScriptExport(props: { onBack: () => void }) {109  const [pass, setPass] = createSignal("");110  const [confirm, setConfirm] = createSignal("");111  const [strengthScore, setStrengthScore] = createSignal<number>(0);112  const [strengthHint, setStrengthHint] = createSignal<string>("");113  const [working, setWorking] = createSignal(false);114  const [script, setScript] = createSignal<string | null>(null);115  const [error, setError] = createSignal<string | null>(null);116  const [entryCount, setEntryCount] = createSignal(0);117  const [stats, setStats] = createSignal<{118    cards: number;119    passkeys: number;120    notes: number;121    logins: number;122  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });123  const [copied, setCopied] = createSignal(false);124125  const passReady = createMemo(126    () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()127  );128129  function handlePassInput(value: string) {130    setPass(value);131    if (!value) {132      setStrengthScore(0);133      setStrengthHint("");134      return;135    }136    // Fire-and-forget: if zxcvbn hasn't loaded yet the signal updates when it does137    void getZxcvbn().then((zxcvbn) => {138      if (pass() !== value) return; // stale — user typed ahead139      const result = zxcvbn(value);140      setStrengthScore(result.score);141      const hint = result.feedback.warning || result.feedback.suggestions[0] || "";142      setStrengthHint(hint);143    });144  }145146  async function handleEncode() {147    setError(null);148    setScript(null);149    setCopied(false);150    if (!passReady()) {151      setError("Backup passwords don't match or are too short.");152      return;153    }154    setWorking(true);155    try {156      const env = await assembleEnvelope(pass());157      setEntryCount(env.entryCount);158      setStats(env.stats);159      // PB-26-113: the ceiling is enforced by the encoder, which measures the160      // encoded file's UTF-8 bytes rather than the envelope's — encoding three161      // bytes per envelope byte is what makes the two differ.162      setScript(encodePeachScript(env.bytes));163    } catch (err) {164      setError(165        err instanceof Error && err.message === "PEACHSCRIPT_TOO_LARGE"166          ? "This vault is too large for a PeachScript backup — use Codex export instead."167          : "Couldn't create PeachScript backup.",168      );169    } finally {170      setWorking(false);171    }172  }173174  async function handleCopy() {175    const s = script();176    if (!s) return;177    try {178      await navigator.clipboard.writeText(s);179      setCopied(true);180      setTimeout(() => setCopied(false), 2000);181    } catch (err) {182      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");183    }184  }185186  function handleDownload() {187    const s = script();188    if (!s) return;189    // .peach file = the same PeachScript text, in a single file. The PUA190    // codepoints render as runes only when paired with PeachScript-Regular.otf;191    // outside Peach the file looks like opaque bytes, which is the point.192    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,193    // hyphens only, lowercase extension, date-only (not time-of-day).194    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });195    const url = URL.createObjectURL(blob);196    const date = new Date().toISOString().slice(0, 10);197    const a = document.createElement("a");198    a.href = url;199    a.download = `peach-backup-${date}.peach`;200    document.body.appendChild(a);201    a.click();202    document.body.removeChild(a);203    setTimeout(() => URL.revokeObjectURL(url), 5000);204  }205206  return (207    <>208      <OverlayHeader title="peachscript" onBack={props.onBack} />209      <div class="peach-settings-body">210        <Show211          when={script()}212          fallback={213            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">214              <p class="peach-settings-label">backup password</p>215              <p class="peach-export-card-copy">216                Choose a password to lock this PeachScript. You'll need it217                to restore on another device.218              </p>219              <div class="peach-settings-stack peach-export-password-stack">220                <input221                  ref={(el) => queueMicrotask(() => el.focus())}222                  class="peach-settings-input"223                  type="password"224                  placeholder="backup password"225                  value={pass()}226                  onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}227                  onKeyDown={(e) => {228                    if (e.key === "Enter" && passReady() && !working()) {229                      e.preventDefault();230                      void handleEncode();231                    }232                  }}233                />234                <Show when={pass().length > 0}>235                  <div class="peach-export-strength-track">236                    <div237                      class="peach-export-strength-fill"238                      style={{239                        width: `${(strengthScore() / 4) * 100}%`,240                        background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],241                      }}242                    />243                  </div>244                  <Show when={strengthScore() < STRENGTH_REQUIRED}>245                    <p class="peach-export-strength-warn">246                      {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}247                    </p>248                  </Show>249                  <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>250                    <p class="peach-export-strength-hint">{strengthHint()}</p>251                  </Show>252                </Show>253                <input254                  class="peach-settings-input"255                  type="password"256                  placeholder="confirm backup password"257                  value={confirm()}258                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}259                  onKeyDown={(e) => {260                    if (e.key === "Enter" && passReady() && !working()) {261                      e.preventDefault();262                      void handleEncode();263                    }264                  }}265                />266                <Show when={confirm().length > 0 && pass() !== confirm()}>267                  <p class="peach-export-note" style="color:var(--peach-t2)">268                    Passwords don't match269                  </p>270                </Show>271              </div>272              <button273                class="peach-btn peach-btn--subtle peach-btn--sm"274                onClick={() => void handleEncode()}275                disabled={working() || !passReady()}276              >277                {working() ? "encoding…" : "Generate PeachScript"}278              </button>279            </div>280          }281        >282          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">283            <p class="peach-settings-label">peachscript</p>284            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />285            <p class="peach-export-card-copy" style="margin:0 0 8px">286              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}287              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}288              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}289              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}290            </p>291            <div style="display:flex;gap:8px;">292              <button293                class="peach-btn peach-btn--subtle peach-btn--sm"294                style="flex:1;"295                onClick={() => void handleCopy()}296              >297                {copied() ? "copied ✓" : "Copy"}298              </button>299              <button300                class="peach-btn peach-btn--subtle peach-btn--sm"301                style="flex:1;"302                onClick={handleDownload}303              >304                Download .peach305              </button>306            </div>307            <p class="peach-export-card-copy" style="margin-top:8px">308              Paste the script — or import the .peach file — into Peach on309              your other device to restore your vault. Won't work anywhere else.310            </p>311          </div>312        </Show>313        <Show when={error()}>314          <div class="peach-settings-group">315            <div class="peach-restore-error">{error()}</div>316          </div>317        </Show>318      </div>319    </>320  );321}322323// ---------------------------------------------------------------------------324// CSV — non-skippable warning, every time, by design (spec §5.2).325// ---------------------------------------------------------------------------326327function CSVExport(props: { onBack: () => void }) {328  const [acknowledged, setAcknowledged] = createSignal(false);329  const [masterPassword, setMasterPassword] = createSignal("");330  const [working, setWorking] = createSignal(false);331  const [error, setError] = createSignal<string | null>(null);332  const [done, setDone] = createSignal<string | null>(null);333334  async function handleExport() {335    setError(null);336    setDone(null);337    setWorking(true);338    try {339      const res = await sendMessage({ type: "EXPORT_ENTRIES", payload: { password: masterPassword() } });340      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];341      // Passkeys are device-bound credentials and cannot be moved between342      // devices via export. Strip the passkey sub-object out of any login343      // entry that carries one; the entry itself still exports if it has a344      // password too. Passkey-only entries (no password) are skipped entirely.345      const active = entries346        .filter((e) => e.deletedAt === undefined)347        .filter((e) => !(e.passkey && !e.password))348        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));349      const excludedPasskeys = entries.filter(350        (e) => e.deletedAt === undefined && e.passkey351      ).length;352353      if (active.length === 0) {354        setError("Your vault has no entries to export.");355        return;356      }357358      const csv = buildCsv(active);359      const filename = `peach-export-${dateStamp()}.csv`;360      downloadBlob(csv, "text/csv", filename);361      const suffix = excludedPasskeys > 0362        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`363        : "";364      setDone(365        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`366      );367    } catch (err) {368      setError(err instanceof Error ? err.message : String(err));369    } finally {370      setWorking(false);371    }372  }373374  return (375    <>376      <OverlayHeader title="export as CSV" onBack={props.onBack} />377      <div class="peach-settings-body">378        <div class="peach-settings-group peach-export-card">379          <div class="peach-csv-warning">380            This export is unencrypted. Anyone who gets this file can read381            your passwords. Only use this to move to another password382            manager — delete it once the import is done.383          </div>384          <Show when={!acknowledged()}>385            <button386              class="peach-btn peach-btn--ghost peach-btn--sm"387              onClick={() => setAcknowledged(true)}388            >389              I understand — continue390            </button>391          </Show>392          <Show when={acknowledged()}>393            <label class="peach-settings-label" for="csv-export-password">master password</label>394            <input395              id="csv-export-password"396              class="peach-settings-input"397              type="password"398              value={masterPassword()}399              onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}400              placeholder="enter your master password"401            />402            <button403              class="peach-btn peach-btn--subtle peach-btn--sm"404              onClick={() => void handleExport()}405              disabled={working()}406            >407              {working() ? "exporting…" : "Download CSV"}408            </button>409          </Show>410        </div>411        <Show when={error()}>412          <div class="peach-settings-group">413            <div class="peach-restore-error">{error()}</div>414          </div>415        </Show>416        <Show when={done()}>417          <div class="peach-settings-group">418            <div class="peach-export-success">419              <span class="peach-export-success-check">✓</span>420              <span>{done()}</span>421            </div>422          </div>423        </Show>424      </div>425    </>426  );427}428429// ---------------------------------------------------------------------------430// Shared helpers431// ---------------------------------------------------------------------------432433function OverlayHeader(props: { title: string; onBack: () => void }) {434  return (435    <div class="peach-overlay-header">436      <button437        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"438        onClick={props.onBack}439        aria-label="Back"440      >441        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">442          <path d="M6 1L1 6L6 11" />443        </svg>444      </button>445      <span class="peach-overlay-title">{props.title}</span>446      <div class="peach-overlay-spacer"></div>447    </div>448  );449}450451interface AssembledEnvelope {452  bytes: Uint8Array;453  entryCount: number;454  stats: { cards: number; passkeys: number; notes: number; logins: number };455}456457async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {458  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });459  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];460461  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });462  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};463464  let license: PeachLicenseExport | undefined;465  try {466    const info = await sendMessage({ type: "GET_LICENSE" });467    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;468    if (data?.valid && data.plan === "pro") {469      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });470      const keyStr = (keyRes as { ok: boolean; data?: string }).data;471      if (keyStr) {472        license = {473          key: keyStr,474          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),475        };476      }477    }478  } catch {479    // License absent — proceed without it.480  }481482  const stats = countTypes(entries);483  const bytes = await buildPayload({484    entries,485    settings: settings as Parameters<typeof buildPayload>[0]["settings"],486    license,487    passphrase,488  });489  return { bytes, entryCount: entries.length, stats };490}491492function countTypes(entries: VaultEntry[]): {493  cards: number;494  passkeys: number;495  notes: number;496  logins: number;497} {498  let cards = 0;499  let passkeys = 0;500  let notes = 0;501  let logins = 0;502  for (const e of entries) {503    if (e.deletedAt) continue;504    if (e.passkey) {505      passkeys++;506      // A login that ALSO has a password counts toward the login total too —507      // exporting it preserves the password (the passkey gets stripped).508      if (e.type === "login" && e.password) logins++;509      continue;510    }511    if (e.type === "card") cards++;512    else if (e.type === "note") notes++;513    else logins++;514  }515  return { cards, passkeys, notes, logins };516}517518// Neutralize spreadsheet formula injection (OWASP): Excel/Sheets/Calc execute519// a cell that begins with = + - @ or a leading tab/CR as a formula, regardless520// of CSV quoting. Vault fields can be attacker-influenced (a malicious site can521// seed an entry username/notes), so a crafted =HYPERLINK/WEBSERVICE/DDE payload522// in the exported CSV could exfiltrate other plaintext cells on open. Prefix523// any such field with a single quote so the cell is treated as text (audit MEDIUM).524export function csvSafe(value: string): string {525  return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value;526}527528function buildCsv(entries: VaultEntry[]): string {529  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];530  const rows = entries.map((e) => {531    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";532    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";533    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";534    return [535      e.title,536      e.url ?? "",537      username,538      password,539      e.notes ?? "",540      type,541      Array.isArray(e.tags) ? e.tags.join("|") : "",542      e.totp ?? "",543    ]544      .map((v) =>545        `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`546      )547      .join(",");548  });549  return [headers.join(","), ...rows].join("\n");550}551552function dateStamp(): string {553  return new Date().toISOString().split("T")[0];554}555556function downloadBlob(content: string, mimeType: string, filename: string): void {557  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));558  const a = document.createElement("a");559  a.href = url;560  a.download = filename;561  document.body.appendChild(a);562  a.click();563  document.body.removeChild(a);564  URL.revokeObjectURL(url);565}