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
↓ peach-browser-337f921a.tar.gzatlas at this save
files changed10 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 −2manifest.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": {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",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": {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-});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-}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 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-}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-});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 }));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));