# Peach Browser Extension — Completion Spec · v1.2 baseline **Generated:** 2026-04-20 **Current version:** 1.1.1 **Target:** production-quality, daily-driver password manager --- ## Context & guiding principle Every decision in this spec is held to one standard: **seamless, magical, snappy, and brilliant.** That means zero friction on the critical path (fill credentials → copy TOTP → move on), zero surprises on import/export, and an interface that rewards the user for every interaction. Slow, janky, or confusing is always a bug. --- ## Baseline state (v1.1.1) ### What works - Popup UI pixel-matched to peachpasswords.com demo; 520 px shell, archive fonts, peach glyph wordmark - AES-256-GCM vault + Argon2id key derivation - HIBP k-anonymity breach monitoring (per-entry count, bulk "check all") - Import: Bitwarden JSON, Proton Pass JSON, 1Password JSON, Chrome/LastPass/1Password CSV, Peach encrypted JSON - Export: Peach encrypted JSON, plaintext CSV, CJK phrase backup (full round-trip, 43 tests) - Content script: basic heuristic field detection, React/Vue-aware fill, shadow-DOM autofill selector dropdown, identity helper on signup forms, TOTP clipboard toast - Settings persistence (theme, accent) through lock/unlock - Auto-lock on system idle (chrome.idle, 60 s threshold) - 49 Vitest tests pass; typecheck clean; build succeeds ### Known gaps (drives this spec) - **Legacy Peach JSON** (`exportFormat: "peach-vault"`) not recognized → silent UNSUPPORTED_IMPORT_FORMAT for any user migrating from peach legacy - **Full ML autofill UX** — ONNX model + feature extractor exist but the trigger loop (field focus → AUTOFILL_QUERY → dropdown → fill → TOTP) is not wired; no mutation observer for SPAs; no field icon; no TOTP autofill; no login-form detection on all fields (currently only signup helper is active) - **QR code export** — not implemented; critical for device-to-device vault transfer and TOTP handoff to authenticator apps - **Bundle size** — main popup chunk is **1016 KB** (uncompressed); first open feels sluggish; target < 250 KB for initial paint - **Polish gaps** — no favicon in autofill dropdown, no fuzzy search, no inline password strength in Add/Edit, no keyboard shortcuts, no entry view animations --- ## Version bump rule Bump `version` in `package.json`, `manifest.json`, and `package-lock.json` **after every task**. Use semantic versioning: - Each task below is a MINOR bump (new user-facing capability) - Polish/splitting tasks are PATCH bumps --- ## Task 1 — Legacy Peach JSON import *(target: v1.1.2)* ### Why Anyone migrating from the legacy peachlegacy codebase exports a `peach-vault` JSON file. That file is currently rejected by `parseImportSource()`. This is a hard blocker for any real migration. ### Legacy format schema The legacy app exports plaintext JSON (no outer encryption) in this shape: ```json { "exportFormat": "peach-vault", "exportFormatVersion": 2, "exportedAt": "2025-01-15T10:00:00.000Z", "exportedAtMs": 1736935200000, "entryCount": 42, "passkeyPrivateKeysWrapped": false, "entries": [ { "id": "", "type": "login", "modified": 1736935100000, "created": 1736930000000, "name": "GitHub", "favorite": false, "tags": [], "login": { "urls": ["https://github.com"], "username": "june", "password": "supersecret", "totp": { "secret": "BASE32SECRET", "algorithm": "SHA1", "digits": 6, "period": 30 }, "customFields": [] } } ] } ``` `type` may also be `"card"`, `"identity"`, or `"note"` — skip those silently (count as skipped items). ### Implementation **`src/shared/import.ts`** 1. Add `"peach-legacy-json"` to the `ImportFormat` union type. 2. In `detectImportFormat()`, before the `csv` fallback, add: ```ts if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json"; ``` 3. Add `parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }`: - Parse JSON; guard on `exportFormat === "peach-vault"`. - `totalItems` = `data.entries.length`. - Filter for `entry.type === "login"`. - Map each login entry: - `title` ← `entry.name ?? "Untitled"` - `url` ← `entry.login.urls?.[0] ?? ""` - `username` ← `entry.login.username ?? ""` - `password` ← `entry.login.password ?? ""` - `totp` ← `entry.login.totp?.secret ?? undefined` - `notes` ← `entry.login.notes ?? undefined` - `createdAt` ← `entry.created ?? Date.now()` - `modifiedAt` ← `entry.modified ?? Date.now()` - Drop passkeys, customFields, tags, favorite (no equivalent fields in `VaultEntry`). Do not silently inflate skipped count for dropped sub-fields — only count whole entries that were skipped. 4. Wire into the `switch` in `parseImportSource()` with `label: "Peach (legacy)"`, `requiresPassword: false`. **`src/popup/views/Import.tsx`** Add `"peach-legacy-json"` to the format label map so the preview header shows `"Peach (legacy)"` instead of the raw format string. **`src/shared/import.test.ts`** Add at minimum: - Parses a valid `peach-vault` export and returns the correct `ImportableEntry[]` - Skips `type !== "login"` entries and reports them in `skippedItems` - Handles missing optional fields (`totp`, `notes`, `urls`) without throwing - Falls back gracefully on malformed JSON (returns `format: "unknown"`) ### Acceptance criteria - `detectImportFormat(legacyJson)` returns `"peach-legacy-json"` - Import preview shows login entries with correct title/url/username - Card, identity, note entries counted in skipped items - `npm test` still passes; `npm run typecheck` clean --- ## Task 2 — QR code export *(target: v1.1.3)* ### Why QR codes are the fastest path for two workflows users need daily: (a) transferring a TOTP secret to a new authenticator app, and (b) securely moving a vault backup to another device without USB or cloud. Both should feel instant and obvious. ### Dependency Add `qrcode` (browser-safe, no Node deps, ~40 KB min+gz): ``` npm install qrcode npm install --save-dev @types/qrcode ``` ### 2a — TOTP QR in entry detail view **`src/popup/views/Entry.tsx`** When an entry has a `totp` field, add a small "show QR" icon/button beside the TOTP display (right of the copy button). On click: - Construct the `otpauth://totp/` URI from the entry: `otpauth://totp/${encodeURIComponent(entry.title)}?secret=${entry.totp}&issuer=${encodeURIComponent(domain)}&algorithm=SHA1&digits=6&period=30` - Generate a QR code as a `data:image/png;base64,...` string using `QRCode.toDataURL(uri, { width: 220, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })` so it matches the Peach palette - Show the QR inside a full-overlay modal (same overlay pattern as other views) with: title "Scan with your authenticator", the QR image centred, the raw URI in a monospace pre block below (scrollable, selectable), and a close button - The overlay is dismissed by pressing Escape or clicking the close button The QR generation (`QRCode.toDataURL`) should be dynamically imported so it does not land in the main chunk: ```ts const { default: QRCode } = await import("qrcode"); ``` **UX requirement:** the QR must be large enough to scan reliably from ~40 cm (220 px at 1× CSS px satisfies this inside the 520 px popup). ### 2b — Vault QR in Export view (CJK phrase → QR) The CJK phrase backup already produces a compact, human-copyable string (~800 CJK characters for a 100-entry vault). A QR code of that string is the ideal device-to-device transfer mechanism — it fits comfortably within QR version 40 capacity (~2,953 alphanumeric chars at ECC M). **`src/popup/views/CJKExport.tsx`** After the CJK phrase is generated and displayed, add a secondary action button "Show as QR" below the phrase display. On click: - Dynamically import `qrcode` - Generate QR of the raw CJK phrase string: `QRCode.toDataURL(phraseString, { errorCorrectionLevel: 'M', width: 420, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })` - Display in the same overlay pattern as 2a, with label "Scan with Peach on another device" and the instruction "Open Peach → Import → CJK phrase, then scan" - Include a copy button for the text phrase so the user can choose either transfer method **Acceptance criteria** - Clicking "show QR" on a TOTP entry renders a scannable QR code that an authenticator app (Google Authenticator, Aegis) reads correctly - Clicking "Show as QR" after CJK export renders a QR that round-trips correctly through CJK import when scanned - Neither import adds to the initial popup JS chunk (dynamic import verified: `npm run build` shows `qrcode` in a separate chunk) - `npm run typecheck` clean --- ## Task 3 — Full ML autofill UX *(target: v1.2.0)* ### Why This is the core daily-driver feature. The legacy system was battle-tested across months of real-world use on thousands of sites. The ML model (`models/form_detector.onnx`) and feature extraction code already exist in peach-browser. What's missing is the full trigger loop and UX surface. This task completes it. ### Architecture overview The flow is: 1. Content script observes field focus events (including inside shadow roots via deep traversal) 2. For any password-type field or ML-classified username/email field in a login context, fire `AUTOFILL_QUERY` with the page's registrable domain 3. Background returns matching vault entries 4. Dropdown appears anchored below the focused field (closed shadow DOM) 5. User selects an entry (click or keyboard) → `GET_ENTRY_FOR_FILL` → `fillFields()` 6. If entry has `totp`, copy to clipboard and show toast "TOTP copied" 7. Mutation observer watches for SPA DOM changes and re-runs detection ### 3a — Deep input traversal **`src/content/detect.ts`** Add `querySelectorAllDeep(root: Document | ShadowRoot | Element, selector: string): T[]`: - Collects matching elements from `root.querySelectorAll(selector)`, then recursively descends into any `element.shadowRoot` found in the tree - Used by `detectLoginFields()` and `collectPasswordFields()` to replace the current flat `document.querySelectorAll` Replace the existing flat `querySelectorAll` in `detectLoginFields()` with `querySelectorAllDeep(document, "input")`. Add `getDeepActiveInput(): HTMLInputElement | null` — walks `document.activeElement`, then repeatedly dereferences `.shadowRoot?.activeElement` until no further shadow host is found. ### 3b — Login-form detection **`src/content/detect.ts`** Add `isLoginForm(fields: DetectedField[]): boolean`: - Returns true if fields contain at least one password field AND at least one username/email field - OR if there is exactly one password field and the autocomplete is `current-password` Add `isSignupForm(fields: DetectedField[]): boolean` (update existing if present): - Returns true if any password field has `autocomplete="new-password"`, name/id containing "new" or "confirm", or there are 2+ password fields in the collection The existing content script (`index.ts`) currently only shows the identity helper on signup forms. This task extends it to also show the autofill dropdown on login forms (using `isLoginForm`). ### 3c — Field icon **`src/content/inject.ts`** Add `injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void`: - Inserts a small 18×18 px circular icon (the peach SVG mark, same as `PeachMark.tsx` but inline SVG) into the field's parent container - Positioned absolute, right-aligned within the field, vertically centered - `z-index: 2147483646`; pointer-events on icon only - Returns a cleanup function that removes the icon - Clicking the icon calls `onClick` (which manually triggers the autofill dropdown even if the field wasn't freshly focused) The icon must not break field layout: set `padding-right` on the field to `field.style.paddingRight + 26px` when injected, restore on cleanup. ### 3d — Autofill dropdown (upgrade `inject.ts`) **`src/content/inject.ts`** Upgrade `showAutofillSelector()` with: - **Favicon**: prepend a 16×16 `` to each row loading `https://icons.duckduckgo.com/ip3/${domain}.ico` (extract domain from `entry.url`), with a fallback to a generic key SVG on error - **Keyboard navigation**: `ArrowDown`/`ArrowUp` moves focus between rows; `Enter` selects; `Escape` dismisses; `Tab` dismisses (don't trap focus) - **Repositioning**: use a `ResizeObserver` + `requestAnimationFrame` loop to keep the dropdown anchored if the field moves (SPA transitions, sticky headers) - **Empty state**: if `entries.length === 0`, show a single row "No saved logins for this site" (non-interactive, muted style) rather than not showing the dropdown at all — gives the user confirmation that Peach is active - **TOTP indicator**: if an entry has a `totp` field, show a small "⏱" badge on the right side of the row ### 3e — Trigger loop **`src/content/index.ts`** — replace/extend the current content script: ``` on document "focusin": field = getDeepActiveInput() if field is null or not visible: return if field type is "password" or ML predicts "username"/"email": fields = detectLoginFields() if isLoginForm(fields): entries = await sendMessage(AUTOFILL_QUERY, { domain: getPageDomain() }) if entries.length > 0: dismiss = showAutofillSelector(field, entries, async (id) => { entry = await sendMessage(GET_ENTRY_FOR_FILL, { id }) fillFields(usernameEl, passwordEl, entry.username, entry.password) if entry.totp: totpCode = await generateTOTP(entry.totp) navigator.clipboard.writeText(totpCode) showToast("TOTP copied · " + totpCode) }) injectFieldIcon(field, () => dismiss()) // icon re-opens on click else if isSignupForm(fields): [existing identity helper logic] ``` The ML classification for the focused field should be done via `ContentMLDetector` from `content-ml-detector.ts` (already exists). Include a 150 ms timeout fallback to heuristic `detectLoginFields()` if ML inference stalls. ### 3f — Mutation observer (SPA support) **`src/content/index.ts`** Add `setupMutationObserver()`: ```ts const observer = new MutationObserver(debounce(() => { // Re-run login field detection on meaningful DOM changes // Only re-attach if new password or username inputs appear that weren't seen before }, 120)); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"] }); ``` Call `setupMutationObserver()` once at content script init. On each mutation batch, check if any previously-unseen password inputs have appeared; if so, re-run field detection and re-attach icon + focus listeners on the new inputs. ### 3g — TOTP autofill If the page has a visible TOTP input (ML label `"totp"` or `autocomplete="one-time-code"` or `name/id` containing `otp|totp|2fa|mfa|code`), and the user just autofilled from a matching entry that has `totp`: - After a 400 ms delay (allow page to react to password fill), check if the TOTP field is still empty - If so, generate the current TOTP code and fill it directly (using `setNativeValue`) - Show toast "TOTP autofilled" ### Acceptance criteria - On a standard login form (GitHub, Notion, Linear) the autofill dropdown appears when the username/email field is focused - Keyboard selection fills both fields without page reload - After fill, TOTP code is on clipboard with toast confirmation - On an SPA that renders a login form after navigation, the dropdown appears on the newly-rendered form - Dropdown appears inside fields rendered in a shadow root - `npm run typecheck` clean; `npm run build` succeeds; `node test-extension.mjs` passes --- ## Task 4 — Code splitting *(target: v1.2.1)* ### Why The main popup JS chunk is **1016 KB**. On first open (cold service worker), this delays interactive time noticeably. The goal is an initial chunk < 250 KB so the vault list renders within one animation frame of popup opening. ### What is heavy | Module | Why heavy | |---|---| | CJKExport + CJKImport | zstd WASM init, argon2 from hash-wasm, diceware wordlist, zxcvbn | | Export | AES-GCM crypto module, CSV serializer | | Import | PapaParse, all format parsers, backup decrypt | | Generator | zxcvbn (already dep but bundled eagerly) | | onnxruntime-web | ONNX inference (should already be background-only, verify) | ### Implementation **`src/popup/App.tsx`** Replace static imports of heavy views with SolidJS lazy components: ```tsx import { lazy } from "solid-js"; const ImportView = lazy(() => import("./views/Import")); const ExportView = lazy(() => import("./views/Export")); const CJKExport = lazy(() => import("./views/CJKExport")); const CJKImport = lazy(() => import("./views/CJKImport")); const SettingsView = lazy(() => import("./views/Settings")); const GeneratorView = lazy(() => import("./views/Generator")); ``` Wrap each lazy usage in `…}>`. The loading state should be a minimal spinner or the Peach wordmark with a subtle pulse — never a blank white flash. **`src/popup/views/Import.tsx` and `Export.tsx`** Move the `qrcode` import (from Task 2) to `() => import("qrcode")` at the call site — already specified in Task 2. Verify no static `qrcode` import lands in the main chunk. **`src/crypto/*.ts`** The CJK modules should already be separate chunks since they're only imported from `CJKExport`/`CJKImport` views. Verify after splitting. If they still appear in the main chunk, add an explicit `/* @vite-ignore */` comment and dynamic-import the crypto entry point from within the lazy view. **`vite.config.ts`** Add manual chunk grouping to keep the split predictable: ```ts build: { target: "esnext", emptyOutDir: false, rollupOptions: { output: { manualChunks: { "vendor-solid": ["solid-js"], "vendor-otp": ["otpauth"], "vendor-parse": ["papaparse"], "vendor-qr": ["qrcode"], } } } } ``` ### Acceptance criteria - `npm run build` output shows no single JS chunk > 350 KB (uncompressed) - The main popup chunk (`index.html-*.js`) is < 250 KB - Opening the popup on a cold start renders the shell (wordmark + lock screen or vault list) within one frame; Import/Export/Settings overlay loads in < 300 ms on first open (Suspense fallback visible only briefly) - All existing Playwright and Vitest tests still pass --- ## Task 5 — Polish pass *(target: v1.2.2)* This task has no new features — it is entirely about feel. Every interaction should be tight. ### 5a — Fuzzy search **`src/popup/views/Vault.tsx`** Replace the current substring search with a lightweight fuzzy match: ```ts function fuzzyMatch(query: string, target: string): boolean { query = query.toLowerCase(); target = target.toLowerCase(); let qi = 0; for (let i = 0; i < target.length && qi < query.length; i++) { if (target[i] === query[qi]) qi++; } return qi === query.length; } ``` Match against `entry.title + " " + entry.url + " " + entry.username`. Sort results so exact prefix matches appear before fuzzy matches. No external dependency. ### 5b — Inline password strength **`src/popup/views/Add.tsx`** Under the password input, add a strength bar using `zxcvbn` (already a dep): - A 4-segment bar (0–4 score), coloured: red → orange → yellow → green - Label: "weak" / "fair" / "good" / "strong" - Only appears once the user has typed ≥ 4 characters - zxcvbn import: `import { default as zxcvbn } from "zxcvbn"` — this module is already in the vendor bundle; no new dependency ### 5c — Keyboard shortcuts **`src/popup/App.tsx`** Global `keydown` listener (when popup is focused): | Key | Action | |---|---| | `Escape` | Close the topmost open overlay (settings, import, export, CJK, generator, add) | | `/` or `Ctrl+F` | Focus the search input (from any unlocked non-overlay state) | | `Ctrl+L` | Lock vault | **`src/popup/views/Entry.tsx`** When an entry is open: | Key | Action | |---|---| | `c` | Copy password | | `u` | Copy username | | `t` | Copy TOTP (if present) | | `e` | Open edit mode | | `Escape` | Back to vault list | ### 5d — Entry favicon **`src/popup/views/Vault.tsx`** and **`src/popup/views/Entry.tsx`** Replace the generic key icon in list rows with a 16×16 favicon loaded from `https://icons.duckduckgo.com/ip3/${registrableDomain}.ico`. On error fall back to the key icon. Cache in a `Map` (domain → data URL) within the component lifetime to avoid redundant fetches as the user scrolls. ### 5e — View transition animations **`src/popup/styles/popup.css`** Overlays currently appear without animation. Add: ```css .peach-overlay-view { animation: overlay-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both; } @keyframes overlay-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } ``` Entry detail navigation (list row → detail panel): ```css .peach-stage-content { transition: opacity 120ms ease, transform 120ms ease; } .peach-stage-content.navigating { opacity: 0; transform: translateX(6px); } ``` Add/remove `.navigating` in `App.tsx` during route transitions using a `createEffect` on the active entry signal. ### 5f — Copy button drain animation (complete the website parity) **`src/popup/views/Entry.tsx`** On successful copy, the copy button should: 1. Switch icon to a checkmark SVG 2. Show a brief CSS `drain` animation (the website demo has this) 3. Revert after 1800 ms This should already be partially present from the v1.0.8 website-parity pass — verify it is wired and working; if not, complete it. ### 5g — Auto-focus discipline Every overlay that opens should immediately focus the first interactive element: - Add entry overlay → focus the title input - Search / vault list → focus the search input - CJK import → focus the passphrase input - QR overlay → focus the close button (nothing to type) Use SolidJS `onMount` with `el.focus()`. ### Acceptance criteria - Typing a partial entry name with gaps finds the entry via fuzzy match - Password strength bar appears while typing in Add view - `Escape` closes the topmost overlay from any state - `/` focuses search from the vault list - `c` copies password on entry detail - Entry list rows show site favicons - Overlays animate in smoothly; no visible layout jump - `npm run typecheck` clean; `npm test` passes; `npm run build` succeeds; `node test-extension.mjs` confirms vault persistence --- ## Task 6 — Final verification gate *(target: v1.2.2 — same as polish)* After all tasks are complete, run the full suite in order. **All four must pass before declaring the spec complete.** ```bash npm run typecheck npm test npm run build DISPLAY=:99 node test-extension.mjs ``` Expected output from smoke test: ``` Extension ID: Popup title: Peach Entry created Entry visible after save: true Create vault visible after reopen: false Unlock visible after reopen: false (or true if lockOnClose is on) Entry still present after unlock: true Done. ``` CONTINUITY.md must be updated with a `[OUTCOMES]` entry covering every task completed. --- ## Appendix A — ML feature vector reference The ONNX model at `public/models/form_detector.onnx` expects a `float32[1,45]` tensor. Features in order: | Index | Feature | Notes | |---|---|---| | 0–7 | `type_text/email/password/tel/number/search/url/other` | one-hot | | 8–14 | `auto_username/email/current-password/new-password/one-time-code/off/other` | one-hot | | 15–18 | `name_has_user/login/email/pass` | regex matchScore | | 19 | `name_length` | / 50 | | 20–23 | `id_has_user/login/email/pass` | regex matchScore | | 24 | `id_length` | / 50 | | 25–27 | `placeholder_has_user/email/pass` | regex matchScore | | 28 | `placeholder_length` | / 100 | | 29–31 | `aria_label_has_user/email/pass` | regex matchScore | | 32 | `aria_label_length` | / 100 | | 33–35 | `parent_is_form/div/section` | one-hot on closest block parent | | 36 | `sibling_count` | / 10 | | 37 | `has_password_sibling` | bool | | 38 | `has_email_sibling` | bool | | 39 | `form_has_submit` | bool | | 40 | `form_action_has_login` | bool | | 41 | `is_required` | bool | | 42 | `has_placeholder` | bool | | 43 | `has_aria_label` | bool | | 44 | `inputmode_numeric` | bool | Output: softmax over `['username', 'password', 'email', 'totp', 'none']`. Argmax taken. --- ## Appendix B — Legacy Peach JSON field mapping | Legacy field | `VaultEntry` field | Notes | |---|---|---| | `entry.name` | `title` | | | `entry.login.urls[0]` | `url` | First URL only | | `entry.login.username` | `username` | | | `entry.login.password` | `password` | | | `entry.login.totp.secret` | `totp` | Drop algorithm/digits/period | | `entry.login.notes` | `notes` | If present | | `entry.created` | `createdAt` | | | `entry.modified` | `modifiedAt` | | | `entry.trashedAt` | `deletedAt` | | | `entry.login.passkey` | — | Drop silently | | `entry.login.customFields` | — | Drop silently | | `entry.tags` / `favorite` | — | Drop silently | | `type !== "login"` | — | Skip, count as skipped | --- ## Appendix C — QR size sanity | Vault size | CJK phrase length | QR version needed | Fits? | |---|---|---|---| | 10 entries | ~120 chars | Version 10 (ECC M) | ✓ | | 50 entries | ~450 chars | Version 25 (ECC M) | ✓ | | 100 entries | ~827 chars | Version 33 (ECC M) | ✓ | | 500 entries | ~3,500 chars | Version 40 (ECC L) | ✓ | | 1,000 entries | ~6,500 chars | > Version 40 | ✗ — fall back to CJK text display only, hide "Show as QR" button | When `phraseString.length > 2900`, hide the "Show as QR" button in `CJKExport.tsx` and show an inline note: "Vault too large for QR — copy the phrase above." --- ## Appendix D — What "complete" means A password manager extension is production-complete when a user can: 1. **First run** — create a vault in < 30 s, feel secure doing it 2. **Daily use** — open any login page, see the credential dropdown appear, click, done 3. **TOTP** — never manually open an authenticator app; code is on clipboard automatically 4. **Recover** — restore their vault from a CJK phrase on a new device in < 2 minutes 5. **Migrate** — import from any mainstream manager (Bitwarden, 1Password, Chrome, LastPass, Proton Pass, legacy Peach) without losing data 6. **Trust** — zero plaintext password ever touches the network; breach status is visible at a glance After v1.2.2 Peach satisfies all six. That is the bar.