Export.tsx

20.6 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 { IMPORT_LIMITS } from "../../shared/import-limits";11import { RuneCanvas } from "../components/RuneCanvas";12import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1314type ExportSubview = "menu" | "peachscript" | "csv";1516interface ExportProps {17  onBack: () => void;18}1920// Lazy-load @zxcvbn-ts — it's heavy (~700 kB) and only needed when the user21// starts typing a backup password. Shared module-level cache so it is only22// fetched once across multiple PeachScriptExport mounts. Replaces the23// unmaintained `zxcvbn` package (LV-8; see Lock.tsx).24type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } };25let _zxcvbn: ZxcvbnFn | null = null;26async function getZxcvbn(): Promise<ZxcvbnFn> {27  if (_zxcvbn) return _zxcvbn;28  const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29    import("@zxcvbn-ts/core"),30    import("@zxcvbn-ts/language-common"),31    import("@zxcvbn-ts/language-en"),32  ]);33  zxcvbnOptions.setOptions({34    translations: en.translations,35    graphs: common.adjacencyGraphs,36    dictionary: { ...common.dictionary, ...en.dictionary },37  });38  _zxcvbn = (pw: string) => zxcvbn(pw);39  return _zxcvbn;40}4142// Backup password must score at least 3 (strong) on the zxcvbn scale (0–4).43// This prevents a targeted wordlist attack from cracking a weak backup in44// seconds even though Argon2id raises the per-guess cost.45const STRENGTH_REQUIRED = 3;4647export function ExportView(props: ExportProps) {48  const [view, setView] = createSignal<ExportSubview>("menu");49  return (50    <div class="peach-overlay-view ov-enter-right" style="display:flex">51      <Show when={view() === "menu"}>52        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />53      </Show>54      <Show when={view() === "peachscript"}>55        <PeachScriptExport onBack={() => setView("menu")} />56      </Show>57      <Show when={view() === "csv"}>58        <CSVExport onBack={() => setView("menu")} />59      </Show>60    </div>61  );62}6364// ---------------------------------------------------------------------------65// Menu66// ---------------------------------------------------------------------------6768function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {69  return (70    <>71      <OverlayHeader title="export" onBack={props.onBack} />72      <div class="peach-settings-body peach-export-menu">73        <div class="peach-settings-group peach-export-card peach-export-card--primary">74          <p class="peach-settings-label">peachscript</p>75          <p class="peach-export-card-copy">76            Your vault as a string of Peach symbols you can paste into Peach77            on another device. Designed for Peach-to-Peach transfer.78          </p>79          <button80            class="peach-btn peach-btn--subtle peach-btn--sm"81            onClick={() => props.onPick("peachscript")}82          >83            Create PeachScript →84          </button>85        </div>8687        <div class="peach-settings-group peach-export-card">88          <p class="peach-settings-label">CSV (leaving peach)</p>89          <p class="peach-export-card-copy">90            Plain-text export for moving into another password manager. Anyone91            with the file can read your passwords.92          </p>93          <button94            class="peach-btn peach-btn--ghost peach-btn--sm"95            onClick={() => props.onPick("csv")}96          >97            Export as CSV →98          </button>99        </div>100      </div>101    </>102  );103}104105// ---------------------------------------------------------------------------106// PeachScript107// ---------------------------------------------------------------------------108109function PeachScriptExport(props: { onBack: () => void }) {110  const [pass, setPass] = createSignal("");111  const [confirm, setConfirm] = createSignal("");112  const [strengthScore, setStrengthScore] = createSignal<number>(0);113  const [strengthHint, setStrengthHint] = createSignal<string>("");114  const [working, setWorking] = createSignal(false);115  const [script, setScript] = createSignal<string | null>(null);116  const [error, setError] = createSignal<string | null>(null);117  const [entryCount, setEntryCount] = createSignal(0);118  const [stats, setStats] = createSignal<{119    cards: number;120    passkeys: number;121    notes: number;122    logins: number;123  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });124  const [copied, setCopied] = createSignal(false);125126  const passReady = createMemo(127    () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()128  );129130  function handlePassInput(value: string) {131    setPass(value);132    if (!value) {133      setStrengthScore(0);134      setStrengthHint("");135      return;136    }137    // Fire-and-forget: if zxcvbn hasn't loaded yet the signal updates when it does138    void getZxcvbn().then((zxcvbn) => {139      if (pass() !== value) return; // stale — user typed ahead140      const result = zxcvbn(value);141      setStrengthScore(result.score);142      const hint = result.feedback.warning || result.feedback.suggestions[0] || "";143      setStrengthHint(hint);144    });145  }146147  async function handleEncode() {148    setError(null);149    setScript(null);150    setCopied(false);151    if (!passReady()) {152      setError("Backup passwords don't match or are too short.");153      return;154    }155    setWorking(true);156    try {157      const env = await assembleEnvelope(pass());158      setEntryCount(env.entryCount);159      setStats(env.stats);160      if (env.bytes.length > IMPORT_LIMITS.MAX_JSON_BYTES) {161        throw new Error("PEACHSCRIPT_EXPORT_TOO_LARGE");162      }163      setScript(encodePeachScript(env.bytes));164    } catch (err) {165      setError(166        err instanceof Error && err.message === "PEACHSCRIPT_EXPORT_TOO_LARGE"167          ? "This backup is too large to import later (limit: 10 MiB)."168          : "Couldn't create PeachScript backup.",169      );170    } finally {171      setWorking(false);172    }173  }174175  async function handleCopy() {176    const s = script();177    if (!s) return;178    try {179      await navigator.clipboard.writeText(s);180      setCopied(true);181      setTimeout(() => setCopied(false), 2000);182    } catch (err) {183      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");184    }185  }186187  function handleDownload() {188    const s = script();189    if (!s) return;190    // .peach file = the same PeachScript text, in a single file. The PUA191    // codepoints render as runes only when paired with PeachScript-Regular.otf;192    // outside Peach the file looks like opaque bytes, which is the point.193    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,194    // hyphens only, lowercase extension, date-only (not time-of-day).195    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });196    const url = URL.createObjectURL(blob);197    const date = new Date().toISOString().slice(0, 10);198    const a = document.createElement("a");199    a.href = url;200    a.download = `peach-backup-${date}.peach`;201    document.body.appendChild(a);202    a.click();203    document.body.removeChild(a);204    setTimeout(() => URL.revokeObjectURL(url), 5000);205  }206207  return (208    <>209      <OverlayHeader title="peachscript" onBack={props.onBack} />210      <div class="peach-settings-body">211        <Show212          when={script()}213          fallback={214            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">215              <p class="peach-settings-label">backup password</p>216              <p class="peach-export-card-copy">217                Choose a password to lock this PeachScript. You'll need it218                to restore on another device.219              </p>220              <div class="peach-settings-stack peach-export-password-stack">221                <input222                  ref={(el) => queueMicrotask(() => el.focus())}223                  class="peach-settings-input"224                  type="password"225                  placeholder="backup password"226                  value={pass()}227                  onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}228                  onKeyDown={(e) => {229                    if (e.key === "Enter" && passReady() && !working()) {230                      e.preventDefault();231                      void handleEncode();232                    }233                  }}234                />235                <Show when={pass().length > 0}>236                  <div class="peach-export-strength-track">237                    <div238                      class="peach-export-strength-fill"239                      style={{240                        width: `${(strengthScore() / 4) * 100}%`,241                        background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],242                      }}243                    />244                  </div>245                  <Show when={strengthScore() < STRENGTH_REQUIRED}>246                    <p class="peach-export-strength-warn">247                      {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}248                    </p>249                  </Show>250                  <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>251                    <p class="peach-export-strength-hint">{strengthHint()}</p>252                  </Show>253                </Show>254                <input255                  class="peach-settings-input"256                  type="password"257                  placeholder="confirm backup password"258                  value={confirm()}259                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}260                  onKeyDown={(e) => {261                    if (e.key === "Enter" && passReady() && !working()) {262                      e.preventDefault();263                      void handleEncode();264                    }265                  }}266                />267                <Show when={confirm().length > 0 && pass() !== confirm()}>268                  <p class="peach-export-note" style="color:var(--peach-t2)">269                    Passwords don't match270                  </p>271                </Show>272              </div>273              <button274                class="peach-btn peach-btn--subtle peach-btn--sm"275                onClick={() => void handleEncode()}276                disabled={working() || !passReady()}277              >278                {working() ? "encoding…" : "Generate PeachScript"}279              </button>280            </div>281          }282        >283          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">284            <p class="peach-settings-label">peachscript</p>285            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />286            <p class="peach-export-card-copy" style="margin:0 0 8px">287              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}288              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}289              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}290              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}291            </p>292            <div style="display:flex;gap:8px;">293              <button294                class="peach-btn peach-btn--subtle peach-btn--sm"295                style="flex:1;"296                onClick={() => void handleCopy()}297              >298                {copied() ? "copied ✓" : "Copy"}299              </button>300              <button301                class="peach-btn peach-btn--subtle peach-btn--sm"302                style="flex:1;"303                onClick={handleDownload}304              >305                Download .peach306              </button>307            </div>308            <p class="peach-export-card-copy" style="margin-top:8px">309              Paste the script — or import the .peach file — into Peach on310              your other device to restore your vault. Won't work anywhere else.311            </p>312          </div>313        </Show>314        <Show when={error()}>315          <div class="peach-settings-group">316            <div class="peach-restore-error">{error()}</div>317          </div>318        </Show>319      </div>320    </>321  );322}323324// ---------------------------------------------------------------------------325// CSV — non-skippable warning, every time, by design (spec §5.2).326// ---------------------------------------------------------------------------327328function CSVExport(props: { onBack: () => void }) {329  const [acknowledged, setAcknowledged] = createSignal(false);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: "GET_ENTRIES" });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            <button394              class="peach-btn peach-btn--subtle peach-btn--sm"395              onClick={() => void handleExport()}396              disabled={working()}397            >398              {working() ? "exporting…" : "Download CSV"}399            </button>400          </Show>401        </div>402        <Show when={error()}>403          <div class="peach-settings-group">404            <div class="peach-restore-error">{error()}</div>405          </div>406        </Show>407        <Show when={done()}>408          <div class="peach-settings-group">409            <div class="peach-export-success">410              <span class="peach-export-success-check">✓</span>411              <span>{done()}</span>412            </div>413          </div>414        </Show>415      </div>416    </>417  );418}419420// ---------------------------------------------------------------------------421// Shared helpers422// ---------------------------------------------------------------------------423424function OverlayHeader(props: { title: string; onBack: () => void }) {425  return (426    <div class="peach-overlay-header">427      <button428        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"429        onClick={props.onBack}430        aria-label="Back"431      >432        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">433          <path d="M6 1L1 6L6 11" />434        </svg>435      </button>436      <span class="peach-overlay-title">{props.title}</span>437      <div class="peach-overlay-spacer"></div>438    </div>439  );440}441442interface AssembledEnvelope {443  bytes: Uint8Array;444  entryCount: number;445  stats: { cards: number; passkeys: number; notes: number; logins: number };446}447448async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {449  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });450  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];451452  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });453  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};454455  let license: PeachLicenseExport | undefined;456  try {457    const info = await sendMessage({ type: "GET_LICENSE" });458    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;459    if (data?.valid && data.plan === "pro") {460      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });461      const keyStr = (keyRes as { ok: boolean; data?: string }).data;462      if (keyStr) {463        license = {464          key: keyStr,465          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),466        };467      }468    }469  } catch {470    // License absent — proceed without it.471  }472473  const stats = countTypes(entries);474  const bytes = await buildPayload({475    entries,476    settings: settings as Parameters<typeof buildPayload>[0]["settings"],477    license,478    passphrase,479  });480  return { bytes, entryCount: entries.length, stats };481}482483function countTypes(entries: VaultEntry[]): {484  cards: number;485  passkeys: number;486  notes: number;487  logins: number;488} {489  let cards = 0;490  let passkeys = 0;491  let notes = 0;492  let logins = 0;493  for (const e of entries) {494    if (e.deletedAt) continue;495    if (e.passkey) {496      passkeys++;497      // A login that ALSO has a password counts toward the login total too —498      // exporting it preserves the password (the passkey gets stripped).499      if (e.type === "login" && e.password) logins++;500      continue;501    }502    if (e.type === "card") cards++;503    else if (e.type === "note") notes++;504    else logins++;505  }506  return { cards, passkeys, notes, logins };507}508509// Neutralize spreadsheet formula injection (OWASP): Excel/Sheets/Calc execute510// a cell that begins with = + - @ or a leading tab/CR as a formula, regardless511// of CSV quoting. Vault fields can be attacker-influenced (a malicious site can512// seed an entry username/notes), so a crafted =HYPERLINK/WEBSERVICE/DDE payload513// in the exported CSV could exfiltrate other plaintext cells on open. Prefix514// any such field with a single quote so the cell is treated as text (audit MEDIUM).515export function csvSafe(value: string): string {516  return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value;517}518519function buildCsv(entries: VaultEntry[]): string {520  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];521  const rows = entries.map((e) => {522    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";523    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";524    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";525    return [526      e.title,527      e.url ?? "",528      username,529      password,530      e.notes ?? "",531      type,532      Array.isArray(e.tags) ? e.tags.join("|") : "",533      e.totp ?? "",534    ]535      .map((v) =>536        `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`537      )538      .join(",");539  });540  return [headers.join(","), ...rows].join("\n");541}542543function dateStamp(): string {544  return new Date().toISOString().split("T")[0];545}546547function downloadBlob(content: string, mimeType: string, filename: string): void {548  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));549  const a = document.createElement("a");550  a.href = url;551  a.download = filename;552  document.body.appendChild(a);553  a.click();554  document.body.removeChild(a);555  URL.revokeObjectURL(url);556}