337f921aba65

remove CJKExport.tsx + cjkPassphrase + their tests; trim backup.ts; bump to v2.5.0; update Projects.md §3.7 and §7.5; session notes outcomes

time
2026-04-27 16:13 UTC · 6 months ago
machine
koh2
parent
964d7e074a21
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/cjkPassphrase.test.ts+0 −82src/crypto/cjkPassphrase.ts+0 −316src/popup/App.tsx+0 −2src/popup/views/CJKExport.tsx+0 −711src/popup/views/cjk-export.test.tsx+0 −192src/popup/views/contracts.test.tsx+0 −10src/shared/backup.ts+9 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.4.5",4+  "version": "2.5.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.4.5",4+  "version": "2.5.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.4.5",3+  "version": "2.5.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/crypto/cjkPassphrase.test.ts+0 −82
@@ -1,82 +0,0 @@1-import { describe, expect, it, vi } from "vitest";2-3-import {4-  MIN_PASSPHRASE_LENGTH,5-  checkPassphraseBreached,6-  generateDicewarePassphrase,7-  validateForExport,8-} from "./cjkPassphrase";9-10-describe("crypto cjkPassphrase", () => {11-  it("generateDicewarePassphrase returns words from the EFF wordlist", () => {12-    const phrase = generateDicewarePassphrase();13-    const words = phrase.split(" ");14-15-    expect(words).toHaveLength(6);16-    expect(words.every((word) => /^[a-z]+(?:-[a-z]+)?$/.test(word))).toBe(true);17-  });18-19-  it("validateForExport fails when the passphrase is too short", () => {20-    expect(21-      validateForExport({22-        passphrase: "too short",23-        confirm: "too short",24-      })25-    ).toMatchObject({26-      ok: false,27-      error: "PASSPHRASE_TOO_SHORT",28-    });29-    expect(MIN_PASSPHRASE_LENGTH).toBe(16);30-  });31-32-  it("validateForExport fails when zxcvbn rates the passphrase too weak", () => {33-    expect(34-      validateForExport({35-        passphrase: "aaaaaaaaaaaaaaaa",36-        confirm: "aaaaaaaaaaaaaaaa",37-      })38-    ).toMatchObject({39-      ok: false,40-      error: "PASSPHRASE_TOO_WEAK",41-    });42-  });43-44-  it("validateForExport accepts a strong long passphrase", () => {45-    expect(46-      validateForExport({47-        passphrase: "correct horse battery staple drift waves",48-        confirm: "correct horse battery staple drift waves",49-      })50-    ).toMatchObject({51-      ok: true,52-    });53-  });54-55-  it("checkPassphraseBreached only sends the 5-char SHA-1 prefix", async () => {56-    const fetchMock = vi.fn(async () => ({57-      ok: true,58-      text: async () => "",59-    }));60-    vi.stubGlobal("fetch", fetchMock);61-62-    await checkPassphraseBreached("correct horse battery staple drift waves");63-64-    expect(fetchMock).toHaveBeenCalledTimes(1);65-    const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];66-    expect(url).toMatch(/\/range\/[A-F0-9]{5}$/);67-    expect(url).not.toContain("correct horse battery");68-  });69-70-  it("checkPassphraseBreached handles network failure without throwing", async () => {71-    vi.stubGlobal("fetch", vi.fn(async () => {72-      throw new Error("network down");73-    }));74-75-    await expect(76-      checkPassphraseBreached("correct horse battery staple drift waves")77-    ).resolves.toEqual({78-      breached: false,79-      apiReachable: false,80-    });81-  });82-});

view file at the parent

src/crypto/cjkPassphrase.ts+0 −316
@@ -1,316 +0,0 @@1-/**2- * Export passphrase validation, strength estimation, and generation3- * (spec §6).4- *5- * Three concerns, kept separate so the UI can call them in the order it6- * wants:7- *8- *   1. `evaluateStrength(phrase)` — synchronous, runs zxcvbn. Used on every9- *      keystroke to update the strength meter live.10- *11- *   2. `checkPassphraseBreached(phrase)` — async, calls HIBP. Debounced at12- *      the UI layer, not here. Non-blocking per §6.3: if the API is13- *      unreachable we return `{ breached: false, apiReachable: false }`14- *      and the UI shows a subtle warning. A positive breach match is15- *      blocking.16- *17- *   3. `validateForExport(...)` — combines the hard requirements (§6.1)18- *      with the confirm-match check. Returns the exact error codes listed19- *      in §9 so the UI has one lookup table for messages.20- *21- *   4. `generateDicewarePassphrase(wordCount)` — 6-word default, CSPRNG22- *      selection from the EFF large wordlist (§6.5).23- */24-25-import zxcvbn from "zxcvbn";26-import effWordlist from "eff-diceware-passphrase/wordlist.json";27-28-// ---------------------------------------------------------------------------29-// Strength tiers (spec §6.2)30-// ---------------------------------------------------------------------------31-32-export type StrengthTier = "weak" | "fair" | "good" | "excellent";33-34-export interface StrengthResult {35-  /** zxcvbn 0..4 score. */36-  score: 0 | 1 | 2 | 3 | 4;37-  tier: StrengthTier;38-  /** True iff this passphrase is strong enough to enable the Export button. */39-  meetsStrengthBar: boolean;40-  /** Estimated guesses, straight from zxcvbn (for display + debug). */41-  guesses: number;42-  /**43-   * First crack-time-seconds bucket from zxcvbn. Handy for UI copy like44-   * "Centuries to crack" without reimplementing the heuristic.45-   */46-  crackTimeDisplay: string;47-  /** First concrete feedback message from zxcvbn, if any. Empty string on "no feedback". */48-  feedback: string;49-}50-51-const ZXCVBN_SCORE_GOOD = 3;52-export const MIN_PASSPHRASE_LENGTH = 16;53-54-/**55- * Map a zxcvbn score 0..4 onto a named tier from the spec table. The56- * mapping is deterministic: 0|1 → weak, 2 → fair, 3 → good, 4 → excellent.57- */58-function tierForScore(score: number): StrengthTier {59-  if (score <= 1) return "weak";60-  if (score === 2) return "fair";61-  if (score === 3) return "good";62-  return "excellent";63-}64-65-/**66- * Synchronous strength evaluation. Safe to call on every keystroke.67- * zxcvbn runs in 5–30ms for typical inputs — if this becomes a hot path68- * the UI can debounce, but we don't add latency here.69- */70-export function evaluateStrength(passphrase: string): StrengthResult {71-  if (passphrase.length === 0) {72-    return {73-      score: 0,74-      tier: "weak",75-      meetsStrengthBar: false,76-      guesses: 0,77-      crackTimeDisplay: "instant",78-      feedback: "",79-    };80-  }81-82-  const result = zxcvbn(passphrase);83-  const score = result.score as 0 | 1 | 2 | 3 | 4;84-  const tier = tierForScore(score);85-86-  // "feedback.warning" is the primary message. "feedback.suggestions[0]"87-  // is a softer hint (e.g., "Add another word or two"). Prefer warning88-  // when present; otherwise surface the first suggestion.89-  const feedback =90-    result.feedback.warning ||91-    (result.feedback.suggestions && result.feedback.suggestions[0]) ||92-    "";93-94-  return {95-    score,96-    tier,97-    meetsStrengthBar: score >= ZXCVBN_SCORE_GOOD,98-    guesses: result.guesses,99-    crackTimeDisplay: String(100-      result.crack_times_display.offline_slow_hashing_1e4_per_second101-    ),102-    feedback,103-  };104-}105-106-// ---------------------------------------------------------------------------107-// HIBP k-anonymity check (spec §6.3)108-// ---------------------------------------------------------------------------109-110-export interface HibpResult {111-  breached: boolean;112-  /** False if the API couldn't be reached. When false, `breached` is also false. */113-  apiReachable: boolean;114-}115-116-const HIBP_RANGE_ENDPOINT = "https://api.pwnedpasswords.com/range/";117-118-/**119- * Check a passphrase against HaveIBeenPwned using the k-anonymity range120- * API. Only the first 5 hex chars of the SHA-1 are sent — the passphrase121- * itself never leaves the browser.122- *123- * The API is treated as advisory. Network failure returns `apiReachable:124- * false`; the caller should allow export with a subtle warning. A positive125- * match always returns `breached: true` and should block export.126- */127-export async function checkPassphraseBreached(128-  passphrase: string,129-  signal?: AbortSignal130-): Promise<HibpResult> {131-  const hashHex = await sha1Hex(passphrase);132-  const prefix = hashHex.slice(0, 5);133-  const suffix = hashHex.slice(5);134-135-  try {136-    const response = await fetch(`${HIBP_RANGE_ENDPOINT}${prefix}`, {137-      method: "GET",138-      // HIBP explicitly sets Access-Control-Allow-Origin: *, so a plain139-      // fetch from the extension page works without host_permissions.140-      headers: { "Add-Padding": "true" },141-      signal,142-    });143-    if (!response.ok) {144-      return { breached: false, apiReachable: false };145-    }146-    const text = await response.text();147-    // Each line is `HASH_SUFFIX:COUNT`. A count of 0 means the hash was148-    // inserted by the "Add-Padding" feature and is not a real match; we149-    // must skip those rows or we'd report false positives.150-    const lines = text.split(/\r?\n/);151-    for (const line of lines) {152-      const [hash, countStr] = line.split(":");153-      if (!hash) continue;154-      if (hash.toUpperCase() !== suffix) continue;155-      const count = Number.parseInt(countStr ?? "0", 10);156-      if (Number.isFinite(count) && count > 0) {157-        return { breached: true, apiReachable: true };158-      }159-    }160-    return { breached: false, apiReachable: true };161-  } catch (err) {162-    // AbortError must propagate — the caller cancelled intentionally.163-    if (err instanceof DOMException && err.name === "AbortError") {164-      throw err;165-    }166-    return { breached: false, apiReachable: false };167-  }168-}169-170-async function sha1Hex(input: string): Promise<string> {171-  const bytes = new TextEncoder().encode(input);172-  const hashBuffer = await crypto.subtle.digest("SHA-1", bytes as BufferSource);173-  const hashBytes = new Uint8Array(hashBuffer);174-  let hex = "";175-  for (let i = 0; i < hashBytes.length; i++) {176-    hex += hashBytes[i].toString(16).padStart(2, "0");177-  }178-  return hex.toUpperCase();179-}180-181-// ---------------------------------------------------------------------------182-// Top-level validation (spec §6.1, §9)183-// ---------------------------------------------------------------------------184-185-export type ExportPassphraseErrorCode =186-  | "PASSPHRASE_TOO_SHORT"187-  | "PASSPHRASE_TOO_WEAK"188-  | "PASSPHRASE_BREACHED"189-  | "PASSPHRASE_MISMATCH";190-191-export interface ValidateForExportInput {192-  passphrase: string;193-  confirm: string;194-  /**195-   * Latest HIBP result, if one is available. If the caller hasn't finished196-   * (or started) the HIBP check, pass `undefined` and validation will197-   * skip the breach gate — suitable for driving the real-time button198-   * state. Only pass a real `HibpResult` at the moment the user clicks199-   * Export.200-   */201-  hibp?: HibpResult;202-}203-204-export interface ValidateForExportResult {205-  ok: boolean;206-  /** The first failure encountered, in severity order. */207-  error?: ExportPassphraseErrorCode;208-  /** Populated even when `ok: true` so the UI can render the strength tier. */209-  strength: StrengthResult;210-}211-212-/**213- * Apply the §6.1 hard requirements plus the confirm-match gate. Order214- * of checks mirrors the order the user experiences problems: length215- * first (typo → short), then strength (user realises they need more216- * words), then breach (surprising, but most passwords aren't breached).217- * Mismatch is last because it only matters once the primary passphrase218- * passes the other three.219- */220-export function validateForExport(221-  input: ValidateForExportInput222-): ValidateForExportResult {223-  const strength = evaluateStrength(input.passphrase);224-225-  if (input.passphrase.length < MIN_PASSPHRASE_LENGTH) {226-    return { ok: false, error: "PASSPHRASE_TOO_SHORT", strength };227-  }228-229-  if (!strength.meetsStrengthBar) {230-    return { ok: false, error: "PASSPHRASE_TOO_WEAK", strength };231-  }232-233-  // A positive breach result blocks. An unreachable API is treated as a234-  // soft pass — the UI still shows a warning, but doesn't block.235-  if (input.hibp?.breached) {236-    return { ok: false, error: "PASSPHRASE_BREACHED", strength };237-  }238-239-  if (input.passphrase !== input.confirm) {240-    return { ok: false, error: "PASSPHRASE_MISMATCH", strength };241-  }242-243-  return { ok: true, strength };244-}245-246-// ---------------------------------------------------------------------------247-// Diceware generator (spec §6.5)248-// ---------------------------------------------------------------------------249-250-/**251- * The EFF large wordlist. Imported directly from the package's JSON so we252- * avoid its Node-only RNG helpers in the popup bundle. 7776 = 6^5 entries, averaging253- * ~12.9 bits of entropy per word.254- */255-const EFF_WORDS: string[] = effWordlist as string[];256-257-const DEFAULT_WORD_COUNT = 6; // ~77 bits of entropy at 12.925 bits/word258-259-/**260- * Generate a diceware passphrase using `crypto.getRandomValues` so the261- * output is CSPRNG-driven rather than Math.random.262- *263- * We select indices by generating 32-bit random values and taking264- * `value % 7776`. 2^32 mod 7776 is non-zero, so this introduces a tiny265- * modulo bias (≈ 1 / 2^19 per selection). For a 6-word passphrase that's266- * well below any practical attack — but if you care, pass a larger267- * `rejectionSampleLimit` to resample out-of-range values.268- *269- * The separator is a single space per spec §6.5 — the resulting string270- * can be pasted into the passphrase and confirm fields directly.271- */272-export function generateDicewarePassphrase(273-  wordCount: number = DEFAULT_WORD_COUNT274-): string {275-  if (!Number.isInteger(wordCount) || wordCount < 1) {276-    throw new Error("generateDicewarePassphrase: wordCount must be ≥ 1");277-  }278-279-  if (!EFF_WORDS || EFF_WORDS.length !== 7776) {280-    throw new Error(281-      `generateDicewarePassphrase: unexpected wordlist size (${EFF_WORDS?.length})`282-    );283-  }284-285-  const listLen = EFF_WORDS.length;286-  // Largest multiple of listLen ≤ 2^32. Values ≥ this cutoff would287-  // introduce modulo bias; we resample them.288-  const bias_cutoff = Math.floor(0x100000000 / listLen) * listLen;289-290-  const words: string[] = [];291-  // Oversample to reduce the chance of needing a second pass.292-  const pool = new Uint32Array(wordCount * 2);293-  crypto.getRandomValues(pool);294-  let poolIdx = 0;295-296-  while (words.length < wordCount) {297-    if (poolIdx >= pool.length) {298-      // Rare: refill the pool from CSPRNG.299-      crypto.getRandomValues(pool);300-      poolIdx = 0;301-    }302-    const v = pool[poolIdx++];303-    if (v >= bias_cutoff) continue; // rejection sample304-    words.push(EFF_WORDS[v % listLen]);305-  }306-307-  return words.join(" ");308-}309-310-/**311- * Approximate entropy (bits) of a diceware passphrase drawn from the312- * EFF large wordlist. Useful for UI copy next to the Generate button.313- */314-export function dicewareEntropyBits(wordCount: number): number {315-  return wordCount * Math.log2(EFF_WORDS.length);316-}

view file at the parent

src/popup/App.tsx+0 −2
@@ -24,7 +24,6 @@2424 2525 const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));2626 const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));27-const CJKExport     = lazy(() => import("./views/CJKExport").then(m => ({ default: m.CJKExportView })));2827 const CJKImport     = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));2928 const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));3029 const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));@@ -364,7 +363,6 @@364363       import("./views/Export").catch(() => {}),365364       import("./views/Health").catch(() => {}),366365       import("./views/TrashView").catch(() => {}),367-      import("./views/CJKExport").catch(() => {}),368366       import("./views/CJKImport").catch(() => {}),369367     ]);370368 

view file

src/popup/views/CJKExport.tsx+0 −711
@@ -1,711 +0,0 @@1-/**2- * CJK phrase export flow (spec §7).3- *4- * Three screens:5- *   1. passphrase entry + strength + confirm + high-security + generate6- *   2. generating (progress labels as each pipeline step runs)7- *   3. phrase display (copy / save-as-file / done)8- *9- * The crypto pipeline (`exportToCJK`) lives in src/crypto. This component10- * is purely UX — it drives the pipeline and reports progress. A11- * passphrase validation error should never appear on screen 2 or 3 since12- * the Export button is disabled until all §6.1 requirements pass.13- */14-15-import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";16-17-import {18-  ARGON2_DEFAULT,19-  exportToCJK,20-} from "../../crypto/cjkExport";21-import {22-  checkPassphraseBreached,23-  evaluateStrength,24-  generateDicewarePassphrase,25-  MIN_PASSPHRASE_LENGTH,26-  validateForExport,27-  type ExportPassphraseErrorCode,28-  type HibpResult,29-  type StrengthTier,30-} from "../../crypto/cjkPassphrase";31-import type { PeachLicenseExport, VaultEntry } from "../../shared/types";32-33-import { sendMessage } from "../App";34-35-// ---------------------------------------------------------------------------36-// Error code → user-facing copy (spec §9)37-// ---------------------------------------------------------------------------38-39-const EXPORT_ERROR_COPY: Record<ExportPassphraseErrorCode, string> = {40-  PASSPHRASE_TOO_SHORT: "Your export passphrase must be at least 16 characters.",41-  PASSPHRASE_TOO_WEAK:42-    "Your export passphrase is too easy to guess. Try a random phrase of 5–6 words.",43-  PASSPHRASE_BREACHED:44-    "This passphrase has appeared in a known data breach. Choose a different one.",45-  PASSPHRASE_MISMATCH: "Passphrases don't match. Re-enter them carefully.",46-};47-48-const STRENGTH_COLOUR: Record<StrengthTier, string> = {49-  weak: "#DC503C",50-  fair: "#D96830",51-  good: "#64D264",52-  excellent: "#5090D8",53-};54-55-const STRENGTH_LABEL: Record<StrengthTier, string> = {56-  weak: "weak",57-  fair: "fair",58-  good: "good",59-  excellent: "excellent",60-};61-62-// ---------------------------------------------------------------------------63-// Component64-// ---------------------------------------------------------------------------65-66-type Screen = "passphrase" | "generating" | "phrase";67-68-type ProgressStage =69-  | "idle"70-  | "deriving-key"71-  | "encrypting"72-  | "encoding"73-  | "done";74-75-const PROGRESS_COPY: Record<ProgressStage, string> = {76-  idle: "",77-  "deriving-key": "Deriving key…",78-  encrypting: "Encrypting…",79-  encoding: "Encoding…",80-  done: "Done.",81-};82-83-interface CJKExportProps {84-  onBack: () => void;85-  onRequiresUnlock?: () => void;86-}87-88-function getPipelineErrorCopy(error: string | undefined): string {89-  if (!error) return "Export failed.";90-  if (error === "EMPTY_VAULT_EXPORT") {91-    return "Your vault has no entries to export.";92-  }93-  if (error === "vault locked" || error === "VAULT_LOCKED") {94-    return "Peach locked before the export could start. Unlock it again and retry.";95-  }96-  if (error === "SERVICE_WORKER_UNAVAILABLE") {97-    return "Peach couldn't reach its background worker. Reopen the popup and try again.";98-  }99-  return `Export failed: ${error}`;100-}101-102-function isLockRecoveryError(error: string | undefined): boolean {103-  return (104-    error === "vault locked" ||105-    error === "VAULT_LOCKED" ||106-    error === "SERVICE_WORKER_UNAVAILABLE"107-  );108-}109-110-export function CJKExportView(props: CJKExportProps) {111-  const [screen, setScreen] = createSignal<Screen>("passphrase");112-113-  const [passphrase, setPassphrase] = createSignal("");114-  const [confirmPhrase, setConfirmPhrase] = createSignal("");115-116-  const [hibp, setHibp] = createSignal<HibpResult | null>(null);117-  const [hibpChecking, setHibpChecking] = createSignal(false);118-  let hibpController: AbortController | null = null;119-  let hibpDebounce: ReturnType<typeof setTimeout> | null = null;120-121-  const [progress, setProgress] = createSignal<ProgressStage>("idle");122-  const [phraseText, setPhraseText] = createSignal("");123-  const [entryCount, setEntryCount] = createSignal(0);124-  const [cjkCharCount, setCjkCharCount] = createSignal(0);125-  const [passkeyCount, setPasskeyCount] = createSignal(0);126-  const [noteCount, setNoteCount] = createSignal(0);127-  const [loginCount, setLoginCount] = createSignal(0);128-  const [trashedCount, setTrashedCount] = createSignal(0);129-  const [copied, setCopied] = createSignal(false);130-  const [pipelineError, setPipelineError] = createSignal<string | null>(null);131-132-  const [showQrOverlay, setShowQrOverlay] = createSignal(false);133-  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);134-135-  createEffect(() => {136-    if (!showQrOverlay()) return;137-    const handler = (e: KeyboardEvent) => {138-      if (e.key === "Escape") handleCloseQr();139-    };140-    window.addEventListener("keydown", handler);141-    onCleanup(() => window.removeEventListener("keydown", handler));142-  });143-144-  // -------------------------------------------------------------------------145-  // Strength + validation memo — recomputed on every input146-  // -------------------------------------------------------------------------147-148-  const strength = createMemo(() => evaluateStrength(passphrase()));149-150-  const liveValidation = createMemo(() =>151-    validateForExport({152-      passphrase: passphrase(),153-      confirm: confirmPhrase(),154-      hibp: hibp() ?? undefined,155-    })156-  );157-158-  const canExport = createMemo(() => liveValidation().ok);159-160-  // -------------------------------------------------------------------------161-  // HIBP: debounced on passphrase change (spec §6.3 non-blocking)162-  // -------------------------------------------------------------------------163-164-  function schedulePwnedCheck(value: string) {165-    if (hibpController) hibpController.abort();166-    if (hibpDebounce) clearTimeout(hibpDebounce);167-    setHibp(null);168-    setHibpChecking(false);169-170-    if (value.length < MIN_PASSPHRASE_LENGTH) return; // don't burn HIBP requests on tiny inputs171-172-    hibpDebounce = setTimeout(() => {173-      const controller = new AbortController();174-      hibpController = controller;175-      setHibpChecking(true);176-      checkPassphraseBreached(value, controller.signal)177-        .then((result) => {178-          // Only apply if the input hasn't changed underneath us.179-          if (passphrase() === value) {180-            setHibp(result);181-          }182-        })183-        .catch(() => {184-          // AbortError — user kept typing. Silent.185-        })186-        .finally(() => {187-          if (hibpController === controller) {188-            setHibpChecking(false);189-          }190-        });191-    }, 500);192-  }193-194-  onCleanup(() => {195-    if (hibpController) hibpController.abort();196-    if (hibpDebounce) clearTimeout(hibpDebounce);197-  });198-199-  function handlePassphraseInput(value: string) {200-    setPassphrase(value);201-    setHibp(null);202-    schedulePwnedCheck(value);203-  }204-205-  function handleGenerate() {206-    const generated = generateDicewarePassphrase(6);207-    setPassphrase(generated);208-    setConfirmPhrase(generated); // auto-confirm so user only needs to copy it209-    schedulePwnedCheck(generated);210-  }211-212-  // -------------------------------------------------------------------------213-  // Export pipeline driver214-  // -------------------------------------------------------------------------215-216-  async function runExport() {217-    if (!canExport()) return;218-    setScreen("generating");219-    setProgress("deriving-key");220-    setPipelineError(null);221-222-    try {223-      // 1. Fetch entries + settings from the background vault224-      const [entriesRes, settingsRes, licenseRes] = await Promise.all([225-        sendMessage({ type: "GET_ENTRIES" }),226-        sendMessage({ type: "GET_SETTINGS" }),227-        sendMessage({ type: "GET_LICENSE" }),228-      ]);229-      const entriesResponse = entriesRes as {230-        ok: boolean;231-        data?: VaultEntry[];232-        error?: string;233-      };234-      const settingsResponse = settingsRes as {235-        ok: boolean;236-        data?: Record<string, unknown>;237-        error?: string;238-      };239-      if (!entriesResponse.ok) {240-        throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");241-      }242-      if (!settingsResponse.ok) {243-        throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");244-      }245-246-      const entries = entriesResponse.data ?? [];247-      if (entries.length === 0) {248-        throw new Error("EMPTY_VAULT_EXPORT");249-      }250-251-      const settings = settingsResponse.data ?? {};252-253-      // Build license export if the vault is Pro.254-      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;255-      let license: PeachLicenseExport | undefined;256-      if (licenseInfo?.valid && licenseInfo.plan === "pro") {257-        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });258-        const keyStr = (keyRes as { ok: boolean; data?: string }).data;259-        if (keyStr) {260-          license = {261-            key: keyStr,262-            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),263-          };264-        }265-      }266-267-      setProgress("deriving-key");268-      await new Promise((r) => setTimeout(r, 0));269-270-      const result = await exportToCJK({271-        entries,272-        settings: settings as any,273-        license,274-        passphrase: passphrase(),275-        argon2: ARGON2_DEFAULT,276-      });277-278-      setProgress("encoding");279-      await new Promise((r) => setTimeout(r, 0));280-281-      setPhraseText(result.phrase);282-      setEntryCount(result.entryCount);283-      setCjkCharCount(result.cjkCharCount);284-      setLoginCount(result.loginCount);285-      setNoteCount(result.noteCount);286-      setPasskeyCount(result.passkeyCount);287-      setTrashedCount(result.trashedCount);288-      setProgress("done");289-      setScreen("phrase");290-    } catch (err) {291-      const message = err instanceof Error ? err.message : String(err);292-      if (isLockRecoveryError(message)) {293-        props.onRequiresUnlock?.();294-        return;295-      }296-      setPipelineError(getPipelineErrorCopy(message));297-      setScreen("passphrase");298-      setProgress("idle");299-    }300-  }301-302-  // -------------------------------------------------------------------------303-  // Phrase-screen actions304-  // -------------------------------------------------------------------------305-306-  async function handleCopy() {307-    try {308-      await navigator.clipboard.writeText(phraseText());309-      setCopied(true);310-      setTimeout(() => setCopied(false), 2000);311-    } catch {312-      // Clipboard blocked — fall back to a manual selection prompt.313-      setCopied(false);314-    }315-  }316-317-  function handleSaveFile() {318-    const dateStamp = new Date().toISOString().split("T")[0];319-    const filename = `peach-backup-${dateStamp}.txt`;320-    const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });321-    const url = URL.createObjectURL(blob);322-    const a = document.createElement("a");323-    a.href = url;324-    a.download = filename;325-    a.click();326-    URL.revokeObjectURL(url);327-  }328-329-  async function handleShowQr() {330-    const byteLen = new TextEncoder().encode(phraseText()).length;331-    if (byteLen > 2800) return;332-    setShowQrOverlay(true);333-    const { default: QRCode } = await import("qrcode");334-    const dataUrl = await QRCode.toDataURL(phraseText(), {335-      errorCorrectionLevel: "L",336-      width: 320,337-      margin: 2,338-      color: { dark: "#E8A87C", light: "#00000000" },339-    });340-    setQrDataUrl(dataUrl);341-  }342-343-  function handleCloseQr() {344-    setShowQrOverlay(false);345-    setQrDataUrl(null);346-  }347-348-  function handleDone() {349-    setPassphrase("");350-    setConfirmPhrase("");351-    setPhraseText("");352-    setShowQrOverlay(false);353-    setQrDataUrl(null);354-    props.onBack();355-  }356-357-  // -------------------------------------------------------------------------358-  // Render359-  // -------------------------------------------------------------------------360-361-  return (362-    <div class="peach-overlay-view ov-enter-right">363-      <div class="peach-overlay-header">364-        <button365-          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"366-          onClick={screen() === "phrase" ? handleDone : props.onBack}367-          aria-label="Back"368-          disabled={screen() === "generating"}369-        >370-          <svg371-            width="7"372-            height="12"373-            viewBox="0 0 7 12"374-            fill="none"375-            stroke="currentColor"376-            stroke-width="1.1"377-            stroke-linecap="round"378-            stroke-linejoin="round"379-          >380-            <path d="M6 1L1 6L6 11" />381-          </svg>382-        </button>383-        <span class="peach-overlay-title">384-          {screen() === "phrase" ? "backup phrase" : "export"}385-        </span>386-        <div class="peach-overlay-spacer"></div>387-      </div>388-389-      <div class="peach-settings-body">390-        <Show when={screen() === "passphrase"}>391-          <PassphraseScreen392-            passphrase={passphrase()}393-            onPassphraseInput={handlePassphraseInput}394-            confirm={confirmPhrase()}395-            onConfirmInput={setConfirmPhrase}396-            strengthTier={strength().tier}397-            strengthScore={strength().score}398-            strengthLabel={strength().feedback}399-            hibp={hibp()}400-            hibpChecking={hibpChecking()}401-            error={liveValidation().error}402-            canExport={canExport()}403-            onGenerate={handleGenerate}404-            onExport={() => void runExport()}405-            pipelineError={pipelineError()}406-          />407-        </Show>408-409-        <Show when={screen() === "generating"}>410-          <GeneratingScreen stage={progress()} />411-        </Show>412-413-        <Show when={screen() === "phrase"}>414-          <PhraseScreen415-            phrase={phraseText()}416-            entryCount={entryCount()}417-            cjkCharCount={cjkCharCount()}418-            loginCount={loginCount()}419-            noteCount={noteCount()}420-            passkeyCount={passkeyCount()}421-            trashedCount={trashedCount()}422-            copied={copied()}423-            onCopy={() => void handleCopy()}424-            onSaveFile={handleSaveFile}425-            onDone={handleDone}426-            onShowQr={() => void handleShowQr()}427-            showQrOverlay={showQrOverlay()}428-            qrDataUrl={qrDataUrl()}429-            onCloseQr={handleCloseQr}430-          />431-        </Show>432-      </div>433-    </div>434-  );435-}436-437-// ---------------------------------------------------------------------------438-// Screen 1: passphrase entry439-// ---------------------------------------------------------------------------440-441-interface PassphraseScreenProps {442-  passphrase: string;443-  onPassphraseInput: (value: string) => void;444-  confirm: string;445-  onConfirmInput: (value: string) => void;446-  strengthTier: StrengthTier;447-  strengthScore: number;448-  strengthLabel: string;449-  hibp: HibpResult | null;450-  hibpChecking: boolean;451-  error?: ExportPassphraseErrorCode;452-  canExport: boolean;453-  onGenerate: () => void;454-  onExport: () => void;455-  pipelineError: string | null;456-}457-458-function PassphraseScreen(props: PassphraseScreenProps) {459-  const barFillPercent = () => {460-    if (props.passphrase.length === 0) return 0;461-    return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;462-  };463-464-  const errorMessage = () =>465-    props.error && props.passphrase.length > 0466-      ? EXPORT_ERROR_COPY[props.error]467-      : null;468-469-  const confirmMatches = () =>470-    props.confirm.length > 0 && props.confirm === props.passphrase;471-472-  return (473-    <>474-      <div class="peach-settings-group">475-        <p class="peach-settings-label">choose a passphrase</p>476-        <p class="peach-settings-copy peach-settings-copy--spacious">477-          Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.478-        </p>479-480-        <div class="peach-settings-actions" style="margin-bottom:8px">481-          <button482-            class="peach-btn peach-btn--ghost peach-btn--sm"483-            onClick={props.onGenerate}484-          >485-            ↻ generate one for me486-          </button>487-        </div>488-489-        <input490-          class="peach-settings-input"491-          type="text"492-          placeholder="passphrase"493-          value={props.passphrase}494-          onInput={(e) =>495-            props.onPassphraseInput((e.target as HTMLInputElement).value)496-          }497-        />498-499-        <Show when={props.passphrase.length > 0}>500-          <div class="peach-strength-row">501-            <div class="peach-strength-track">502-              <div503-                class="peach-strength-fill"504-                style={{505-                  width: `${barFillPercent()}%`,506-                  background: STRENGTH_COLOUR[props.strengthTier],507-                }}508-              />509-            </div>510-            <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>511-              {STRENGTH_LABEL[props.strengthTier]}512-            </span>513-          </div>514-        </Show>515-516-        <Show when={props.passphrase.length > 0 && !confirmMatches()}>517-          <input518-            class="peach-settings-input"519-            type="text"520-            placeholder="confirm passphrase"521-            value={props.confirm}522-            onInput={(e) =>523-              props.onConfirmInput((e.target as HTMLInputElement).value)524-            }525-          />526-        </Show>527-528-        <Show when={confirmMatches()}>529-          <div class="peach-inline-feedback peach-inline-feedback--ok">530-            ✓ passphrases match531-          </div>532-        </Show>533-534-        <Show when={errorMessage()}>535-          <div class="peach-inline-feedback peach-inline-feedback--error">536-            {errorMessage()}537-          </div>538-        </Show>539-540-        <Show when={props.hibpChecking}>541-          <div class="peach-inline-feedback peach-inline-feedback--muted">542-            Checking passphrase…543-          </div>544-        </Show>545-        <Show when={props.hibp && !props.hibp.apiReachable}>546-          <div class="peach-inline-feedback peach-inline-feedback--muted">547-            Couldn't reach breach database — proceed with care.548-          </div>549-        </Show>550-551-        <Show when={props.pipelineError}>552-          <div class="peach-inline-feedback peach-inline-feedback--error">553-            {props.pipelineError}554-          </div>555-        </Show>556-557-        <div class="peach-settings-actions" style="margin-top:10px">558-          <button559-            class="peach-btn peach-btn--subtle peach-btn--sm"560-            onClick={props.onExport}561-            disabled={!props.canExport}562-          >563-            Create backup phrase →564-          </button>565-        </div>566-      </div>567-    </>568-  );569-}570-571-// ---------------------------------------------------------------------------572-// Screen 2: generating573-// ---------------------------------------------------------------------------574-575-interface GeneratingScreenProps {576-  stage: ProgressStage;577-}578-579-function GeneratingScreen(props: GeneratingScreenProps) {580-  return (581-    <div class="peach-settings-group peach-progress-screen">582-      <div class="peach-progress-screen-title">Creating your backup phrase…</div>583-      <div class="peach-spinner" aria-label="Working" />584-      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>585-    </div>586-  );587-}588-589-// ---------------------------------------------------------------------------590-// Screen 3: phrase display591-// ---------------------------------------------------------------------------592-593-interface PhraseScreenProps {594-  phrase: string;595-  entryCount: number;596-  cjkCharCount: number;597-  loginCount: number;598-  noteCount: number;599-  passkeyCount: number;600-  trashedCount: number;601-  copied: boolean;602-  onCopy: () => void;603-  onSaveFile: () => void;604-  onDone: () => void;605-  onShowQr: () => void;606-  showQrOverlay: boolean;607-  qrDataUrl: string | null;608-  onCloseQr: () => void;609-}610-611-function PhraseScreen(props: PhraseScreenProps) {612-  const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;613-  const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;614-615-  return (616-    <>617-      <div class="peach-settings-group">618-        <p class="peach-settings-label">your backup phrase</p>619-        <p class="peach-settings-copy peach-settings-copy--spacious">620-          Copy this phrase and store it somewhere safe. You need this phrase621-          and your passphrase to restore your vault.622-        </p>623-624-        <div class="peach-cjk-phrase-box">{props.phrase}</div>625-626-        <div class="peach-inline-feedback peach-inline-feedback--muted">627-          {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}628-          {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}629-          {" · "}{props.cjkCharCount.toLocaleString()} characters630-        </div>631-        <div class="peach-inline-feedback peach-inline-feedback--muted">632-          {props.loginCount} login{props.loginCount === 1 ? "" : "s"}633-          {props.noteCount > 0634-            ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`635-            : ""}636-          {props.passkeyCount > 0637-            ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`638-            : ""}639-          {" · settings included"}640-        </div>641-642-        <div class="peach-settings-actions">643-          <button644-            class="peach-btn peach-btn--subtle peach-btn--sm"645-            onClick={props.onCopy}646-          >647-            {props.copied ? "Copied ✓" : "Copy phrase"}648-          </button>649-          <button650-            class="peach-btn peach-btn--ghost peach-btn--sm"651-            onClick={props.onSaveFile}652-          >653-            Save as file654-          </button>655-        </div>656-657-        <Show when={phraseTooLongForQr()}>658-          <div class="peach-settings-copy" style="margin-top:12px">659-            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.660-          </div>661-        </Show>662-        <Show when={!phraseTooLongForQr()}>663-          <div class="peach-settings-stack" style="margin-top:12px">664-            <button665-              class="peach-btn peach-btn--ghost peach-btn--sm"666-              onClick={props.onShowQr}667-            >668-              Show as QR669-            </button>670-          </div>671-        </Show>672-673-        <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">674-          <button675-            class="peach-btn peach-btn--ghost peach-btn--sm"676-            onClick={props.onDone}677-          >678-            Done679-          </button>680-        </div>681-      </div>682-683-      <Show when={props.showQrOverlay && props.qrDataUrl}>684-        <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>685-          <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>686-            <div class="peach-overlay-header">687-              <span class="peach-overlay-title">scan with Peach</span>688-              <div class="peach-overlay-spacer" />689-              <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">690-                <CloseGlyph />691-              </button>692-            </div>693-            <div class="peach-cjk-qr-body">694-              <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>695-              <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />696-            </div>697-          </div>698-        </div>699-      </Show>700-    </>701-  );702-}703-704-function CloseGlyph() {705-  return (706-    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">707-      <line x1="2" y1="2" x2="10" y2="10" />708-      <line x1="10" y1="2" x2="2" y2="10" />709-    </svg>710-  );711-}

view file at the parent

src/popup/views/cjk-export.test.tsx+0 −192
@@ -1,192 +0,0 @@1-// @vitest-environment jsdom2-3-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4-5-import type { VaultEntry } from "../../shared/types";6-import { CJKExportView } from "./CJKExport";7-import { flushView, inputText, renderView } from "./view-test-utils";8-9-const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();10-const exportToCJKMock = vi.fn<(input: unknown) => Promise<unknown>>();11-12-vi.mock("../App", () => ({13-  sendMessage: (msg: unknown) => sendMessageMock(msg),14-}));15-16-vi.mock("../../crypto/cjkExport", () => ({17-  ARGON2_DEFAULT: {18-    memoryKiB: 65536,19-    iterations: 3,20-    parallelism: 4,21-  },22-  exportToCJK: (input: unknown) => exportToCJKMock(input),23-}));24-25-vi.mock("../../crypto/cjkPassphrase", () => ({26-  MIN_PASSPHRASE_LENGTH: 16,27-  evaluateStrength: () => ({28-    tier: "excellent",29-    score: 4,30-    feedback: "strong",31-  }),32-  validateForExport: ({33-    passphrase,34-    confirm,35-  }: {36-    passphrase: string;37-    confirm: string;38-  }) =>39-    passphrase.length >= 16 && passphrase === confirm40-      ? { ok: true }41-      : { ok: false, error: "PASSPHRASE_TOO_SHORT" },42-  checkPassphraseBreached: vi.fn(async () => ({43-    breached: false,44-    count: 0,45-    apiReachable: true,46-  })),47-  generateDicewarePassphrase: () => "correct horse battery staple drift waves",48-}));49-50-describe("CJKExportView", () => {51-  const disposers: Array<() => void> = [];52-53-  beforeEach(() => {54-    document.body.innerHTML = "";55-    sendMessageMock.mockReset();56-    exportToCJKMock.mockReset();57-  });58-59-  afterEach(() => {60-    while (disposers.length > 0) {61-      disposers.pop()?.();62-    }63-  });64-65-  function mount() {66-    const mounted = renderView(<CJKExportView onBack={vi.fn()} />);67-    disposers.push(mounted.dispose);68-    return mounted.container;69-  }70-71-  async function unlockExport(container: HTMLElement) {72-    const passphrase = container.querySelector(73-      'input[placeholder="passphrase"]'74-    ) as HTMLInputElement;75-    inputText(passphrase, "correct horse battery staple");76-    await flushView();77-78-    const confirm = container.querySelector(79-      'input[placeholder="confirm passphrase"]'80-    ) as HTMLInputElement;81-    inputText(confirm, "correct horse battery staple");82-    await flushView();83-  }84-85-  function findButton(container: HTMLElement, text: string): HTMLButtonElement {86-    return Array.from(container.querySelectorAll("button")).find(87-      (candidate) => candidate.textContent?.trim() === text88-    ) as HTMLButtonElement;89-  }90-91-  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {92-    return {93-      id: "entry-1",94-      type: "login",95-      title: "GitHub",96-      username: "june",97-      password: "secret123",98-      url: "https://github.com",99-      createdAt: 1,100-      modifiedAt: 1,101-      ...overrides,102-    };103-  }104-105-  it("routes back to unlock when the background is unavailable during export", async () => {106-    const onRequiresUnlock = vi.fn();107-    sendMessageMock.mockImplementation(async (msg: unknown) => {108-      const type = (msg as { type?: string }).type;109-      if (type === "GET_ENTRIES") {110-        return { ok: false, error: "SERVICE_WORKER_UNAVAILABLE" };111-      }112-      return { ok: true, data: {} };113-    });114-    const mounted = renderView(115-      <CJKExportView onBack={vi.fn()} onRequiresUnlock={onRequiresUnlock} />116-    );117-    disposers.push(mounted.dispose);118-    const container = mounted.container;119-120-    await unlockExport(container);121-    findButton(container, "Create backup phrase →").click();122-    await flushView();123-    await flushView();124-125-    expect(exportToCJKMock).not.toHaveBeenCalled();126-    expect(onRequiresUnlock).toHaveBeenCalled();127-  });128-129-  it("exports all entries including trashed and shows counts", async () => {130-    sendMessageMock.mockImplementation(async (msg: unknown) => {131-      const type = (msg as { type?: string }).type;132-      if (type === "GET_ENTRIES") {133-        return {134-          ok: true,135-          data: [136-            sampleEntry(),137-            sampleEntry({138-              id: "note-1",139-              type: "note",140-              title: "Recovery codes",141-              notes: "a1b2-c3d4",142-              password: undefined,143-              username: undefined,144-              url: undefined,145-            }),146-            sampleEntry({147-              id: "deleted-1",148-              title: "Old account",149-              deletedAt: 2,150-            }),151-          ],152-        };153-      }154-      if (type === "GET_SETTINGS") {155-        return { ok: true, data: { autoLockMinutes: 30 } };156-      }157-      return { ok: true };158-    });159-    exportToCJKMock.mockResolvedValue({160-      phrase: "peach:v3:entries=3:zstd:aes256gcm:argon2id\n漢字漢字",161-      entryCount: 3,162-      cjkCharCount: 4,163-      loginCount: 1,164-      noteCount: 1,165-      passkeyCount: 0,166-      trashedCount: 1,167-      binary: new Uint8Array([1, 2, 3]),168-    });169-    const container = mount();170-171-    await unlockExport(container);172-    findButton(container, "Create backup phrase →").click();173-    await flushView();174-    await flushView();175-    await flushView();176-177-    expect(exportToCJKMock).toHaveBeenCalledWith({178-      entries: [179-        expect.objectContaining({ id: "entry-1", title: "GitHub" }),180-        expect.objectContaining({ id: "note-1", title: "Recovery codes" }),181-        expect.objectContaining({ id: "deleted-1", title: "Old account", deletedAt: 2 }),182-      ],183-      settings: { autoLockMinutes: 30 },184-      passphrase: "correct horse battery staple",185-      argon2: expect.objectContaining({ memoryKiB: 65536 }),186-    });187-    expect(container.textContent).toContain("2 entries");188-    expect(container.textContent).toContain("1 in trash");189-    expect(container.textContent).toContain("1 login");190-    expect(container.textContent).toContain("1 note");191-  });192-});

view file at the parent

src/popup/views/contracts.test.tsx+0 −10
@@ -17,7 +17,6 @@1717 const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();1818 const triggerRingMock = vi.fn();1919 const triggerPipPulseMock = vi.fn();20-const exportEntriesToPeachJsonMock = vi.fn();2120 const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();2221 const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2322 @@ -27,10 +26,6 @@2726   triggerPipPulse: () => triggerPipPulseMock(),2827 }));2928 30-vi.mock("../../shared/backup", () => ({31-  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32-}));33-3429 vi.mock("../../shared/import", () => ({3530   parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),3631 }));@@ -55,10 +50,6 @@5550   CJKImportView: () => null,5651 }));5752 58-vi.mock("./CJKExport", () => ({59-  CJKExportView: () => null,60-}));61-6253 vi.mock("zxcvbn", () => ({6354   default: (value: string) => zxcvbnMock(value),6455 }));@@ -71,7 +62,6 @@7162     sendMessageMock.mockReset();7263     triggerRingMock.mockReset();7364     triggerPipPulseMock.mockReset();74-    exportEntriesToPeachJsonMock.mockReset();7565     parseImportSourceMock.mockReset();7666     zxcvbnMock.mockReset();7767     zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));

view file

src/shared/backup.ts+9 −2
@@ -1,6 +1,14 @@11 import { decrypt, encrypt } from "../background/aes";22 import type { PeachLicenseExport, VaultEntry } from "./types";33 4+/**5+ * Encrypted Peach JSON envelope (legacy v2). v2.5.0 removed the6+ * encrypted-JSON export option from the UI in favour of PeachScript and the7+ * QR matrix code (see src/crypto/peachPayload.ts). The import path remains8+ * so users can still restore older `.json` backups, and the export helper9+ * below is kept private to tests that exercise the import path with10+ * round-trip fixtures.11+ */412 export interface PeachExportEnvelope {513   version: 2;614   type: "peach-export";@@ -13,6 +21,7 @@1321 1422 export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;1523 24+/** @internal — test fixture generator only. No UI code path calls this. */1625 export async function exportEntriesToPeachJson(1726   entries: VaultEntry[],1827   password: string,@@ -20,8 +29,6 @@2029 ): Promise<PeachExportEnvelope> {2130   const salt = crypto.getRandomValues(new Uint8Array(16));2231   const key = await derivePeachExportKey(password, salt);23-  // Inner payload: { entries, license? } — allows license restore on import.24-  // Old exports contained only the entries array; importers handle both shapes.2532   const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };2633   if (license) inner.license = license;2734   const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));

view file