Export.tsx

19.3 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 — it's heavy (~800 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.22type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string; suggestions: string[] } };23let _zxcvbn: ZxcvbnFn | null = null;24async function getZxcvbn(): Promise<ZxcvbnFn> {25  if (_zxcvbn) return _zxcvbn;26  const mod = await import("zxcvbn");27  _zxcvbn = mod.default as ZxcvbnFn;28  return _zxcvbn;29}3031// Backup password must score at least 3 (strong) on the zxcvbn scale (0–4).32// This prevents a targeted wordlist attack from cracking a weak backup in33// seconds even though Argon2id raises the per-guess cost.34const STRENGTH_REQUIRED = 3;3536export function ExportView(props: ExportProps) {37  const [view, setView] = createSignal<ExportSubview>("menu");38  return (39    <div class="peach-overlay-view ov-enter-right" style="display:flex">40      <Show when={view() === "menu"}>41        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />42      </Show>43      <Show when={view() === "peachscript"}>44        <PeachScriptExport onBack={() => setView("menu")} />45      </Show>46      <Show when={view() === "csv"}>47        <CSVExport onBack={() => setView("menu")} />48      </Show>49    </div>50  );51}5253// ---------------------------------------------------------------------------54// Menu55// ---------------------------------------------------------------------------5657function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {58  return (59    <>60      <OverlayHeader title="export" onBack={props.onBack} />61      <div class="peach-settings-body peach-export-menu">62        <div class="peach-settings-group peach-export-card peach-export-card--primary">63          <p class="peach-settings-label">peachscript</p>64          <p class="peach-export-card-copy">65            Your vault as a string of Peach symbols you can paste into Peach66            on another device. Designed for Peach-to-Peach transfer.67          </p>68          <button69            class="peach-btn peach-btn--subtle peach-btn--sm"70            onClick={() => props.onPick("peachscript")}71          >72            Create PeachScript →73          </button>74        </div>7576        <div class="peach-settings-group peach-export-card">77          <p class="peach-settings-label">CSV (leaving peach)</p>78          <p class="peach-export-card-copy">79            Plain-text export for moving into another password manager. Anyone80            with the file can read your passwords.81          </p>82          <button83            class="peach-btn peach-btn--ghost peach-btn--sm"84            onClick={() => props.onPick("csv")}85          >86            Export as CSV →87          </button>88        </div>89      </div>90    </>91  );92}9394// ---------------------------------------------------------------------------95// PeachScript96// ---------------------------------------------------------------------------9798function PeachScriptExport(props: { onBack: () => void }) {99  const [pass, setPass] = createSignal("");100  const [confirm, setConfirm] = createSignal("");101  const [strengthScore, setStrengthScore] = createSignal<number>(0);102  const [strengthHint, setStrengthHint] = createSignal<string>("");103  const [working, setWorking] = createSignal(false);104  const [script, setScript] = createSignal<string | null>(null);105  const [error, setError] = createSignal<string | null>(null);106  const [entryCount, setEntryCount] = createSignal(0);107  const [stats, setStats] = createSignal<{108    cards: number;109    passkeys: number;110    notes: number;111    logins: number;112  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });113  const [copied, setCopied] = createSignal(false);114115  const passReady = createMemo(116    () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()117  );118119  function handlePassInput(value: string) {120    setPass(value);121    if (!value) {122      setStrengthScore(0);123      setStrengthHint("");124      return;125    }126    // Fire-and-forget: if zxcvbn hasn't loaded yet the signal updates when it does127    void getZxcvbn().then((zxcvbn) => {128      if (pass() !== value) return; // stale — user typed ahead129      const result = zxcvbn(value);130      setStrengthScore(result.score);131      const hint = result.feedback.warning || result.feedback.suggestions[0] || "";132      setStrengthHint(hint);133    });134  }135136  async function handleEncode() {137    setError(null);138    setScript(null);139    setCopied(false);140    if (!passReady()) {141      setError("Backup passwords don't match or are too short.");142      return;143    }144    setWorking(true);145    try {146      const env = await assembleEnvelope(pass());147      setEntryCount(env.entryCount);148      setStats(env.stats);149      setScript(encodePeachScript(env.bytes));150    } catch (err) {151      setError(err instanceof Error ? err.message : String(err));152    } finally {153      setWorking(false);154    }155  }156157  async function handleCopy() {158    const s = script();159    if (!s) return;160    try {161      await navigator.clipboard.writeText(s);162      setCopied(true);163      setTimeout(() => setCopied(false), 2000);164    } catch (err) {165      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");166    }167  }168169  function handleDownload() {170    const s = script();171    if (!s) return;172    // .peach file = the same PeachScript text, in a single file. The PUA173    // codepoints render as runes only when paired with PeachScript-Regular.otf;174    // outside Peach the file looks like opaque bytes, which is the point.175    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,176    // hyphens only, lowercase extension, date-only (not time-of-day).177    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });178    const url = URL.createObjectURL(blob);179    const date = new Date().toISOString().slice(0, 10);180    const a = document.createElement("a");181    a.href = url;182    a.download = `peach-backup-${date}.peach`;183    document.body.appendChild(a);184    a.click();185    document.body.removeChild(a);186    setTimeout(() => URL.revokeObjectURL(url), 5000);187  }188189  return (190    <>191      <OverlayHeader title="peachscript" onBack={props.onBack} />192      <div class="peach-settings-body">193        <Show194          when={script()}195          fallback={196            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">197              <p class="peach-settings-label">backup password</p>198              <p class="peach-export-card-copy">199                Choose a password to lock this PeachScript. You'll need it200                to restore on another device.201              </p>202              <div class="peach-settings-stack peach-export-password-stack">203                <input204                  ref={(el) => queueMicrotask(() => el.focus())}205                  class="peach-settings-input"206                  type="password"207                  placeholder="backup password"208                  value={pass()}209                  onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}210                  onKeyDown={(e) => {211                    if (e.key === "Enter" && passReady() && !working()) {212                      e.preventDefault();213                      void handleEncode();214                    }215                  }}216                />217                <Show when={pass().length > 0}>218                  <div class="peach-export-strength-track">219                    <div220                      class="peach-export-strength-fill"221                      style={{222                        width: `${(strengthScore() / 4) * 100}%`,223                        background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],224                      }}225                    />226                  </div>227                  <Show when={strengthScore() < STRENGTH_REQUIRED}>228                    <p class="peach-export-strength-warn">229                      {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}230                    </p>231                  </Show>232                  <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>233                    <p class="peach-export-strength-hint">{strengthHint()}</p>234                  </Show>235                </Show>236                <input237                  class="peach-settings-input"238                  type="password"239                  placeholder="confirm backup password"240                  value={confirm()}241                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}242                  onKeyDown={(e) => {243                    if (e.key === "Enter" && passReady() && !working()) {244                      e.preventDefault();245                      void handleEncode();246                    }247                  }}248                />249                <Show when={confirm().length > 0 && pass() !== confirm()}>250                  <p class="peach-export-note" style="color:var(--peach-t2)">251                    Passwords don't match252                  </p>253                </Show>254              </div>255              <button256                class="peach-btn peach-btn--subtle peach-btn--sm"257                onClick={() => void handleEncode()}258                disabled={working() || !passReady()}259              >260                {working() ? "encoding…" : "Generate PeachScript"}261              </button>262            </div>263          }264        >265          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">266            <p class="peach-settings-label">peachscript</p>267            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />268            <p class="peach-export-card-copy" style="margin:0 0 8px">269              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}270              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}271              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}272              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}273            </p>274            <div style="display:flex;gap:8px;">275              <button276                class="peach-btn peach-btn--subtle peach-btn--sm"277                style="flex:1;"278                onClick={() => void handleCopy()}279              >280                {copied() ? "copied ✓" : "Copy"}281              </button>282              <button283                class="peach-btn peach-btn--subtle peach-btn--sm"284                style="flex:1;"285                onClick={handleDownload}286              >287                Download .peach288              </button>289            </div>290            <p class="peach-export-card-copy" style="margin-top:8px">291              Paste the script — or import the .peach file — into Peach on292              your other device to restore your vault. Won't work anywhere else.293            </p>294          </div>295        </Show>296        <Show when={error()}>297          <div class="peach-settings-group">298            <div class="peach-restore-error">{error()}</div>299          </div>300        </Show>301      </div>302    </>303  );304}305306// ---------------------------------------------------------------------------307// CSV — non-skippable warning, every time, by design (spec §5.2).308// ---------------------------------------------------------------------------309310function CSVExport(props: { onBack: () => void }) {311  const [acknowledged, setAcknowledged] = createSignal(false);312  const [working, setWorking] = createSignal(false);313  const [error, setError] = createSignal<string | null>(null);314  const [done, setDone] = createSignal<string | null>(null);315316  async function handleExport() {317    setError(null);318    setDone(null);319    setWorking(true);320    try {321      const res = await sendMessage({ type: "GET_ENTRIES" });322      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];323      // Passkeys are device-bound credentials and cannot be moved between324      // devices via export. Strip the passkey sub-object out of any login325      // entry that carries one; the entry itself still exports if it has a326      // password too. Passkey-only entries (no password) are skipped entirely.327      const active = entries328        .filter((e) => e.deletedAt === undefined)329        .filter((e) => !(e.passkey && !e.password))330        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));331      const excludedPasskeys = entries.filter(332        (e) => e.deletedAt === undefined && e.passkey333      ).length;334335      if (active.length === 0) {336        setError("Your vault has no entries to export.");337        return;338      }339340      const csv = buildCsv(active);341      const filename = `peach-export-${dateStamp()}.csv`;342      downloadBlob(csv, "text/csv", filename);343      const suffix = excludedPasskeys > 0344        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`345        : "";346      setDone(347        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`348      );349    } catch (err) {350      setError(err instanceof Error ? err.message : String(err));351    } finally {352      setWorking(false);353    }354  }355356  return (357    <>358      <OverlayHeader title="export as CSV" onBack={props.onBack} />359      <div class="peach-settings-body">360        <div class="peach-settings-group peach-export-card">361          <div class="peach-csv-warning">362            This export is unencrypted. Anyone who gets this file can read363            your passwords. Only use this to move to another password364            manager — delete it once the import is done.365          </div>366          <Show when={!acknowledged()}>367            <button368              class="peach-btn peach-btn--ghost peach-btn--sm"369              onClick={() => setAcknowledged(true)}370            >371              I understand — continue372            </button>373          </Show>374          <Show when={acknowledged()}>375            <button376              class="peach-btn peach-btn--subtle peach-btn--sm"377              onClick={() => void handleExport()}378              disabled={working()}379            >380              {working() ? "exporting…" : "Download CSV"}381            </button>382          </Show>383        </div>384        <Show when={error()}>385          <div class="peach-settings-group">386            <div class="peach-restore-error">{error()}</div>387          </div>388        </Show>389        <Show when={done()}>390          <div class="peach-settings-group">391            <div class="peach-export-success">392              <span class="peach-export-success-check">✓</span>393              <span>{done()}</span>394            </div>395          </div>396        </Show>397      </div>398    </>399  );400}401402// ---------------------------------------------------------------------------403// Shared helpers404// ---------------------------------------------------------------------------405406function OverlayHeader(props: { title: string; onBack: () => void }) {407  return (408    <div class="peach-overlay-header">409      <button410        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"411        onClick={props.onBack}412        aria-label="Back"413      >414        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">415          <path d="M6 1L1 6L6 11" />416        </svg>417      </button>418      <span class="peach-overlay-title">{props.title}</span>419      <div class="peach-overlay-spacer"></div>420    </div>421  );422}423424interface AssembledEnvelope {425  bytes: Uint8Array;426  entryCount: number;427  stats: { cards: number; passkeys: number; notes: number; logins: number };428}429430async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {431  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });432  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];433434  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });435  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};436437  let license: PeachLicenseExport | undefined;438  try {439    const info = await sendMessage({ type: "GET_LICENSE" });440    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;441    if (data?.valid && data.plan === "pro") {442      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });443      const keyStr = (keyRes as { ok: boolean; data?: string }).data;444      if (keyStr) {445        license = {446          key: keyStr,447          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),448        };449      }450    }451  } catch {452    // License absent — proceed without it.453  }454455  const stats = countTypes(entries);456  const bytes = await buildPayload({457    entries,458    settings: settings as Parameters<typeof buildPayload>[0]["settings"],459    license,460    passphrase,461  });462  return { bytes, entryCount: entries.length, stats };463}464465function countTypes(entries: VaultEntry[]): {466  cards: number;467  passkeys: number;468  notes: number;469  logins: number;470} {471  let cards = 0;472  let passkeys = 0;473  let notes = 0;474  let logins = 0;475  for (const e of entries) {476    if (e.deletedAt) continue;477    if (e.passkey) {478      passkeys++;479      // A login that ALSO has a password counts toward the login total too —480      // exporting it preserves the password (the passkey gets stripped).481      if (e.type === "login" && e.password) logins++;482      continue;483    }484    if (e.type === "card") cards++;485    else if (e.type === "note") notes++;486    else logins++;487  }488  return { cards, passkeys, notes, logins };489}490491function buildCsv(entries: VaultEntry[]): string {492  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];493  const rows = entries.map((e) => {494    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";495    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";496    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";497    return [498      e.title,499      e.url ?? "",500      username,501      password,502      e.notes ?? "",503      type,504      Array.isArray(e.tags) ? e.tags.join("|") : "",505      e.totp ?? "",506    ]507      .map((v) =>508        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`509      )510      .join(",");511  });512  return [headers.join(","), ...rows].join("\n");513}514515function dateStamp(): string {516  return new Date().toISOString().split("T")[0];517}518519function downloadBlob(content: string, mimeType: string, filename: string): void {520  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));521  const a = document.createElement("a");522  a.href = url;523  a.download = filename;524  document.body.appendChild(a);525  a.click();526  document.body.removeChild(a);527  URL.revokeObjectURL(url);528}