ad53ccec2956

trash 24 stale root files; clean dist dirs on every build (v1.9.7)

time
2026-04-25 01:42 UTC · 6 months ago
machine
koh2
parent
cf2435008e01
25 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+2 −2peach-site.pngbinarypeach_audit_instructions.docxbinarypeach_audit_v130.md+0 −135peach_audit_v1410.md+0 −354peach_audit_v1424.md+0 −529peach_audit_v1425.md+0 −259peach_audit_v1430.md+0 −283peach_completion_spec.md+0 −604peach_daily_driver_build_guide.docxbinarypeach_daily_driver_build_guide.md+0 −1084peach_demo_update_spec.md+0 −1102peach_faithful_spec.md+0 −1252peach_gaps_spec.md+0 −813peach_icon_injection_spec.md+0 −235peach_passkeys_spec.md+0 −669peach_shine_spec.md+0 −624peach_sync_spec.md+0 −407peach_vault_integrity_spec.md+0 −824scripts/clean-chrome-dist.mjs+15 −0scripts/clean-firefox-artifacts.mjs+4 −4status.md+0 −193test-extension.mjs+0 −139
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.9.6",4+  "version": "1.9.7",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": "1.9.6",4+  "version": "1.9.7",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "1.9.6",3+  "version": "1.9.7",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {77     "dev": "vite",88     "build": "npm run build:chrome && npm run build:firefox",9-    "build:chrome": "BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",9+    "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",1010     "build:firefox": "node scripts/clean-firefox-artifacts.mjs && BUILD_TARGET=firefox vite build",1111     "build:e2e": "PEACH_E2E_PASSKEYS=1 BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",1212     "package:firefox": "node scripts/clean-firefox-artifacts.mjs && npm run build:firefox && cd dist-firefox && web-ext build --overwrite-dest",

view file

peach-site.pngbinary

binary file, not shown

view file at the parent

peach_audit_instructions.docxbinary

binary file, not shown

view file at the parent

peach_audit_v130.md+0 −135
@@ -1,135 +0,0 @@1-# Peach Extension — v1.3.0 Audit2-_April 2026_3-4----5-6-## Summary7-8-v1.3.0 is a genuinely impressive ship. All eleven gap-spec tasks and the full Peach Sync foundation are confirmed implemented in source. The extension now has licensing, canary entries, duress password, secure notes, tags, health dashboard, QR codes, site policy detection, legacy import, and a working Firefox build. Two real bugs need fixing before any money changes hands, and the main bundle needs one more targeted split.9-10----11-12-## Gap Spec — Task-by-Task Status13-14-### ✅ Task 0 — Website copy (Firefox/Android "available now")15-Cannot verify directly — the peachpasswords folder is not mounted in this session. No CONTINUITY entry confirms the change. **Treat as unverified; check `src/data/site.ts` in the peachpasswords repo before any public announcement.**16-17-### ✅ Task 1 — Ed25519 licensing18-`src/background/license.ts` is fully implemented. `validateLicense()` decodes the `PPK-` prefixed base58 string, checks the 76-byte structure (12-byte payload + 64-byte Ed25519 signature), verifies against the hardcoded public key via `crypto.subtle.verify("Ed25519", ...)`, and returns `{ valid, plan, issued }`. `activateLicense()`, `getLicenseInfo()`, and `isProUser()` are all present. License is persisted at `peach_license` in `chrome.storage.local`. Settings.tsx wires in the activate form with error/success states. The Pro gate overlay on Sync settings is rendered.19-20-### ✅ Task 2 — Canary entries21-`VaultEntry` has `canary?: Canary` with `webhookUrl`, `label`, `lastPinged`, `pingCount`. Entry.tsx fires `FIRE_CANARY_PING` on copy, autofill, and submit — with a session-level suppress toggle (🪤 badge visible). Add.tsx has full canary configuration UI including webhook test. Background handler for `FIRE_CANARY_PING` dispatches fire-and-forget fetch.22-23-### ✅ Task 3 — Duress password24-`vault.ts` has `vaultMode: "real" | "decoy"`. Unlock tries the real vault, then falls back to `peach_decoy_vault` if duress is enabled and decoy decryption succeeds. All writes use `vaultMode` to select the storage key. `setupDuress()`, `disableDuress()`, and `isDuressEnabled()` are implemented. Message handlers: `IS_DURESS_ENABLED`, `SETUP_DURESS`, `DISABLE_DURESS`, `VERIFY_MAIN_PASSWORD` all present in `messages.ts`.25-26-### ✅ Task 4 — Secure notes27-`VaultEntry` has `type?: "login" | "note"`. Add.tsx renders a two-tab form (Login / Note) via `selectedType` signal. Note-only entries suppress username/password/URL fields. The health dashboard correctly filters out notes from weak/old/reused password checks.28-29-### ✅ Task 5 — One-tap breach regeneration30-`Entry.tsx` has `showRegenPanel` signal and renders the inline regen panel below the breach badge. The panel is implemented (confirmed at lines 306 and 349).31-32-### ✅ Task 6 — QR codes33-Two QR paths confirmed:34-- **CJK phrase QR** — `CJKExport.tsx` dynamically imports `qrcode`, encodes the phrase string, shows "vault too large" message when it won't fit, with a toggleable overlay.35-- **TOTP QR** — `Entry.tsx` dynamically imports `qrcode` and renders the `otpauth://` URI as a QR modal, triggered by a dedicated glyph button.36-37-Both use `import("qrcode")` so the 23KB `vendor-qr` chunk is only fetched on demand.38-39-### ✅ Task 7 — Site password policy detection40-`content/index.ts` implements `detectPasswordPolicy()` reading `maxlength`, `pattern` attributes, and nearby DOM text (via `aria-describedby`, walking up 3 parent levels to find `.hint`/`.requirement` elements). `GET_SITE_PASSWORD_POLICY` message handler is wired at line 437. `PasswordPolicy` interface is in `shared/types.ts`.41-42-### ✅ Task 8 — Tags43-`VaultEntry` has `tags?: string[]`. Add.tsx has a chip-input with 32-char limit, 10-tag cap, and backspace-to-remove. Vault.tsx renders a horizontal scrollable tag filter strip; selecting multiple tags AND-filters the entry list alongside the text search.44-45-### ✅ Task 9 — Health dashboard46-`src/popup/views/Health.tsx` is fully implemented (282 lines). Four collapsible sections: Breached (sorted by exposure count with `formatExposure`), Reused (SHA-256 hash comparison across all login entries, groups of 2+), Weak (zxcvbn score ≤ 1), Old (365-day threshold, `modifiedAt`). Each row has a Fix button. "Check all breaches" triggers `CHECK_ALL_BREACHES`. Lazy-loaded in App.tsx at the `"health"` view.47-48-### ✅ Task 10 — Legacy Peach JSON import49-`import.ts` has `parsePeachLegacyJSON()` that handles the `exportFormat: "peach-vault"` shape. Maps `entry.name → title`, `login.urls[0] → url`, `login.username`, `login.password`, `login.totp.secret → totp`, `login.notes`, `entry.created/modified → createdAt/modifiedAt`. Non-login types (card, identity, etc.) are skipped with a count. `detectImportFormat()` checks `data.exportFormat === "peach-vault"` before all other formats.50-51-### ✅ Task 11 — Firefox extension52-`manifest.firefox.json` exists with `browser_specific_settings.gecko.id = "[email protected]"`. `webextension-polyfill` and `web-ext` are in `package.json`. `build:firefox` and `package:firefox` scripts are present. `vite.config.ts` selects the correct manifest via `BUILD_TARGET` env var and outputs to `dist-firefox/`. Both `dist-chrome/` and `dist-firefox/` are populated with identical JS chunks.53-54----55-56-## Peach Sync — Status57-58-All TypeScript infrastructure is in place: `src/shared/sync-types.ts`, `src/sync/noise.ts`, `src/sync/manifest.ts`, `src/background/sync.ts` (Native Messaging adapter), `src/background/sync-storage.ts`, `src/background/sync-network.ts`. SyncPairing.tsx and the Settings sync panel are implemented with the Pro gate overlay.59-60-The native Rust host (`native/peach-sync-host/`) is not a scaffold — it's a real implementation: `main.rs` (tokio async, graceful shutdown, structured logging), `lib.rs` (331 lines, HostCore), `noise.rs` (174 lines), `mdns.rs` (153 lines), `tcp.rs` (225 lines), `message.rs`. Total: 883 lines of Rust.61-62-What the native host **does not yet have**: a native messaging host manifest (`com.peachpasswords.sync.json`), an install script (macOS: `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, Windows registry, Linux `~/.config/google-chrome/NativeMessagingHosts/`), or a release binary. Users cannot use Peach Sync without these — the extension will silently fail to connect.63-64----65-66-## Bug Reports67-68-### 🔴 BUG 1 — `nativeMessaging` missing from Chrome manifest69-70-`manifest.json` (Chrome) permissions: `["storage", "activeTab", "scripting", "clipboardWrite", "alarms", "idle"]`71-72-`manifest.firefox.json` permissions include `"nativeMessaging"`.73-74-Chrome will reject `chrome.runtime.connectNative("com.peachpasswords.sync")` with a permission error unless `nativeMessaging` is declared. The sync UI will appear for Pro users, they'll try to pair, and it will silently fail. Add `"nativeMessaging"` to the Chrome `manifest.json` permissions array.75-76-### 🟡 BUG 2 — Main bundle still 900KB77-78-The dist-chrome build shows:79-```80-App-Bv6360ZC.js   900KB  ← core popup bundle81-domain-rLGCi-Ud   106KB  ← tldts (content script dep, probably fine)82-CJKExport          91KB  ← lazy ✅83-index.esm          44KB  ← noble/ciphers84-vendor-otp         25KB  ← otpauth85-vendor-qr          23KB  ← qrcode (lazy ✅)86-vendor-parse       19KB  ← papaparse87-vendor-solid       18KB  ← solid-js88-Health-D3mE3jHt   8.6KB  ← lazy ✅89-Settings-DIO2aRqb 6.5KB  ← lazy ✅90-Export-DbL4ASvV   4.4KB  ← lazy ✅91-Generator-FAdaEqya 4.0KB ← lazy ✅92-```93-94-The lazy splitting is working correctly. The 900KB App chunk is dominated by `zxcvbn` (~800KB gzipped: ~200KB). zxcvbn is only needed in the Health view and the Generator strength meter. Both are already lazy chunks, but something is importing zxcvbn into the main path. Find the `import zxcvbn from "zxcvbn"` at the top of `Health.tsx` (line 2) — that import pulls zxcvbn into the lazy chunk, but if any component rendered eagerly also references it (even indirectly), the bundler will hoist it to the main chunk. Audit all non-lazy view files for zxcvbn usage. If the Generator strength bar uses it inline, the fix is to dynamically import zxcvbn within the Generator component.95-96-Splitting zxcvbn out of the main chunk alone would bring the App bundle from ~900KB to ~100–120KB, well under the Chrome Web Store's 4MB popup limit and meaningfully faster to parse on first open.97-98----99-100-## Test Coverage101-102-Seven test files exist: `vault.test.ts`, `cjk.test.ts`, `cjkPayload.test.ts`, `cjkRoundTrip.test.ts`, `cjkSerialise.test.ts`, `crypto.test.ts`, `import.test.ts`. Tests could not be executed in this audit environment (rolldown requires a native arm64 binary not available in the sandbox). Run `npm test` on your local machine to confirm current pass rate. The import tests in particular should cover the new `parsePeachLegacyJSON` path.103-104----105-106-## Chrome Web Store Readiness107-108-Before submitting:109-110-- **Fix the `nativeMessaging` permission** (Bug 1 above). Without it, Sync doesn't work and the store review may flag the missing declaration.111-- **Verify website copy** — confirm Firefox and Android no longer say "available now" in `peachpasswords/src/data/site.ts`.112-- **Native host installer** — without a packaged installer and the host manifest JSON file, Peach Sync is a non-functional Pro feature. Either ship the installer alongside the extension or gate the Sync UI behind a "requires desktop companion" notice until the installer is ready.113-- **Privacy policy URL** — the store listing requires a URL. The security page at peachpasswords.com/security partially covers this but a formal `/privacy` page is expected.114-- **Store listing assets** — screenshots (1280×800 or 640×400), promotional tile (440×280), short description (132 chars max), detailed description. These don't exist yet.115-- **Single-purpose statement** — Chrome requires extensions to have a single, clear purpose. "Zero-knowledge password manager" is clean; make sure the listing doesn't mention future Android/Firefox plans as if they're current.116-117----118-119-## What's Left Before Selling120-121-| Item | Status |122-|------|--------|123-| All gap spec features | ✅ Implemented |124-| Peach Sync TS infrastructure | ✅ Implemented |125-| Native sync host (source) | ✅ Implemented |126-| Native host installer + host manifest | ❌ Missing |127-| `nativeMessaging` in Chrome manifest | ❌ Missing |128-| zxcvbn out of main bundle | ⚠️ Should fix |129-| Website copy (Firefox/Android) | ⚠️ Unverified |130-| Privacy policy page | ❌ Missing |131-| Store listing copy + screenshots | ❌ Missing |132-| Payment flow (Gumroad/Stripe → license key delivery) | ❌ Not reviewed |133-| `npm test` passing locally | ⚠️ Unverified in sandbox |134-135-The extension itself is feature-complete. The remaining work is packaging, infrastructure, and store listing — not feature development.

view file at the parent

peach_audit_v1410.md+0 −354
@@ -1,354 +0,0 @@1-# Peach v1.4.10 — Codebase Audit & State of the Union2-3-_Every source file read, every message handler traced, every security surface examined._4-5----6-7-## 0. Verdict8-9-Peach went from skeleton to working extension in a single session and it shows remarkable structural soundness — the cryptographic core (AES-256-GCM, Argon2id KDF, HIBP k-anonymity, CBOR passkey encoding) is all correctly implemented. The extension is ready for careful daily driving with one critical fix and three high-priority fixes applied first. There are no credential-leaking bugs. There are no plaintext vault bugs. The passkey flow is real and correct. What's broken is largely wiring — a null return where a policy lookup should happen, a WeakMap that doesn't invalidate on SPA navigation, sync handlers that are stubs.10-11----12-13-## 1. Files Audited14-15-| Layer | Files |16-|---|---|17-| Background | entry.ts, vault.ts, messages.ts, alarms.ts, aes.ts, license.ts, passkey.ts, passkey-crypto.ts, sync.ts, sync-storage.ts, sync-network.ts, clipboard.ts |18-| Content | index.ts, inject.ts, detect.ts, fill.ts, save.ts |19-| Crypto | totp.ts, argon2.ts (path traced), aes.ts |20-| Shared | types.ts, generate.ts, domain.ts, sync-types.ts |21-| Popup | App.tsx, Entry.tsx (key sections) |22-| Build | manifest.json, CONTINUITY.md |23-24----25-26-## 2. Critical Bugs (fix before daily driving)27-28-### C-1: Site password policy is permanently broken29-30-**File:** `src/background/messages.ts` line 222  31-**File:** `src/popup/views/GeneratorDrawer.tsx` (caller)32-33-```ts34-case "GET_SITE_PASSWORD_POLICY":35-  return { ok: true, data: null };   // always returns null36-```37-38-The background handler for `GET_SITE_PASSWORD_POLICY` simply returns `null`. The content script *does* have a listener that can detect `maxLength`, `minLength`, `requiresSymbols`, etc. from the live page — but it only responds to messages from the extension's own popup. The GeneratorDrawer sends this message to the **background** (which ignores it), not to the content script directly.39-40-**Impact:** Every password generated from the entry detail view ignores the site's actual constraints. Users on sites with strict max-length rules (e.g., many banks cap at 16 characters) will silently generate passwords that get truncated or rejected.41-42-**Fix:** In `messages.ts`, forward `GET_SITE_PASSWORD_POLICY` to the active tab's content script:43-44-```ts45-case "GET_SITE_PASSWORD_POLICY": {46-  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });47-  if (!tab?.id) return { ok: true, data: null };48-  try {49-    const result = await chrome.tabs.sendMessage(tab.id, { type: "GET_SITE_PASSWORD_POLICY" });50-    return result;51-  } catch {52-    return { ok: true, data: null };53-  }54-}55-```56-57----58-59-## 3. High Priority (fix this week)60-61-### H-1: Autofill icon not re-injected after SPA navigation62-63-**File:** `src/content/index.ts`, `processedInputs` WeakMap64-65-When a React/Vue/Angular SPA replaces the DOM without a full navigation, password fields are rebuilt. The WeakMap entry for the old element is garbage-collected, but the cleanup function it held (which removes the injected icon button) was already called during the DOM removal. New instances of the same logical field get new `HTMLInputElement` objects — the WeakMap will not have them, so `reattachListeners()` *should* work.66-67-However, the `debouncedReattach` function only fires for **password** fields:68-69-```ts70-const newPasswords = detectLoginFields().filter(71-  (f) => f.type === "password" && !processedInputs.has(f.el)72-);73-```74-75-Username/email fields never get icons reattached. If a SPA swaps to a username-first flow (common in modern auth), the icon never appears on the username field.76-77-**Fix:** Change the filter to include `username` and `email` types, matching the same criteria used in `onFocusIn`.78-79----80-81-### H-2: TOTP clipboard write silently fails on unfocused pages82-83-**File:** `src/content/index.ts` line 26084-85-```ts86-await navigator.clipboard.writeText(code);87-```88-89-The content script calls `navigator.clipboard` directly. In Chromium, this requires the page to have focus **and** the `clipboard-write` permission granted by the page. When autofill is triggered from the Peach popup (which has focus), the page may not have focus, causing this to silently fail. The extension has `clipboardWrite` in its manifest permissions — but that covers `chrome.clipboard`, not `navigator.clipboard` from content scripts.90-91-**Fix:** Send a `COPY` message to the background instead:92-93-```ts94-chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP" } });95-```96-97-The background `copyToClipboard()` function uses `chrome.scripting.executeScript` with the extension's clipboard permission, which is reliable regardless of page focus.98-99----100-101-### H-3: All entries sent to content script when no domain match102-103-**File:** `src/content/index.ts` lines 361–362104-105-```ts106-const entries = await fetchAutofillEntries(domain);107-const allEntries = entries.length === 0 ? await fetchAutofillEntries(domain, true) : entries;108-```109-110-When `entries` is empty (no saved login for this domain), the code calls `fetchAutofillEntries(domain, true)` which sends `includeAll: true` to the background. The background returns every vault entry's `id`, `title`, `username`, `url`, and `totp` secret to the content script.111-112-This means: visiting any page with a login form that you haven't saved a password for causes your entire vault index — usernames, entry titles, all associated URLs, and all TOTP secrets — to be sent into that page's content script context. If a rogue page has injected a script that can intercept extension messages (possible via a compromised dependency), this is a significant exposure.113-114-The TOTP secrets are particularly sensitive: they're the raw base32 seeds, not generated codes.115-116-**Fix:** Only populate `searchableEntries` with metadata stripped to `{id, title}` — no `username`, `url`, or `totp`. The search display in the selector only needs title for matching, and `username` is only shown as a sub-label. Even if shown, the actual secret is only fetched via `GET_ENTRY_FOR_FILL` on selection, which is gated by user interaction.117-118-Alternatively: only send `allEntries` for the search header display (which needs titles), and strip `totp` unconditionally.119-120----121-122-### H-4: Idle lock fires after 60 seconds of inactivity123-124-**File:** `src/background/alarms.ts` line 22125-126-```ts127-chrome.idle.setDetectionInterval(60);   // 60 seconds128-```129-130-**File:** `src/background/alarms.ts` lines 29–32131-132-```ts133-chrome.idle.onStateChanged.addListener((state) => {134-  if (state === "idle" || state === "locked") lock();135-});136-```137-138-The Chrome `idle` API transitions to `"idle"` after the configured interval of no user input anywhere in the browser. At 60 seconds, the vault will lock while the user is reading an article, watching a video, or has the browser in the background. The default auto-lock alarm (15 minutes via `touchAutoLock`) is reasonable, but this system-level idle detection is far too aggressive.139-140-The minimum Chrome allows is 15 seconds; 60 is still too short for daily driving. The setting should be raised significantly (5–10 minutes minimum) or made user-configurable. The `"locked"` state (system screen lock) is the right one to act on immediately; `"idle"` alone warrants a configurable threshold.141-142-**Fix:**143-```ts144-chrome.idle.setDetectionInterval(5 * 60); // 5 minutes, or pull from settings145-```146-147----148-149-## 4. Security Findings150-151-### S-1: No URL validation on canary webhook152-153-**File:** `src/background/messages.ts` lines 177–193154-155-`FIRE_CANARY_PING` fetches `webhookUrl` directly from the vault entry with no validation. A maliciously crafted entry (e.g., via import) could point `webhookUrl` at `http://localhost/admin` or an internal network address, turning the extension into an SSRF gadget within the user's network.156-157-**Fix:** Validate that `webhookUrl` is an `https://` URL before fetching. Reject anything with a private IP or `localhost`.158-159----160-161-### S-2: License key stored unencrypted162-163-**File:** `src/background/license.ts`164-165-The license key string is stored in `chrome.storage.local` unencrypted. It can be read or replaced via DevTools → Application → Extension Storage. Since the license is verified via Ed25519 signature and the public key is hardcoded, a valid key can't be fabricated — but an existing key can be extracted and shared. More practically, the license can be deleted from storage, potentially reverting the user to free tier unexpectedly.166-167-This is low-severity (the crypto is correct) but worth noting.168-169----170-171-### S-3: Settings stored unencrypted172-173-**File:** `src/background/vault.ts`, `persistSettings()`174-175-Theme, breach monitoring toggle, auto-lock preference, and `passkeyProviderEnabled` are stored in `chrome.storage.local` without encryption. This is intentional (settings need to be readable before vault unlock) but `passkeyProviderEnabled` has security implications — a local attacker could flip it. Not a realistic threat model for a browser extension, but worth documenting.176-177----178-179-### S-4: E2E passkey bypass in production build (verify)180-181-**File:** `src/background/passkey.ts`182-183-```ts184-const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;185-```186-187-If `__PEACH_E2E_PASSKEYS__` is defined as `true` in a production build (via Vite define), the Pro license check for passkey creation is skipped. Verify `vite.config.ts` does not set this in the production build configuration.188-189----190-191-### S-5: Service worker restart drops passkey approval callbacks192-193-**File:** `src/background/passkey.ts`194-195-The in-memory `approvals` Map stores `{ resolve, reject }` callbacks for pending passkey requests. If the MV3 service worker is terminated between `promptUserApproval()` and the popup responding (common — Chrome terminates idle workers after ~30 seconds), the callback is lost. The 60-second timeout will eventually call `rejectCreate/rejectGet`, but the request hangs silently until then and the user sees no error in the popup.196-197-This is a fundamental MV3 limitation. The existing `chrome.storage.session` usage for the pending request metadata is correct — but the callbacks themselves can't be serialized. Worth adding a startup check that rejects any stale pending requests from a previous worker lifetime.198-199----200-201-## 5. Functional Gaps202-203-### F-1: Sync is architecture-only, not wired204-205-**Files:** `src/background/messages.ts`, `src/background/sync.ts`206-207-The message handlers for `TRIGGER_SYNC`, `START_PAIRING`, `CANCEL_PAIRING`, and `ACCEPT_PAIRING` all return `{ ok: true }` without calling anything:208-209-```ts210-case "TRIGGER_SYNC":211-  return { ok: true };   // no-op212-case "START_PAIRING":213-  return { ok: true };   // no-op214-```215-216-`sync.ts` is a complete, well-structured native messaging client for the `com.peachpasswords.sync` host. The Noise NK protocol is defined in `src/sync/noise.ts`. The storage layer (`sync-storage.ts`) is fully implemented. The UI in `SyncPairing.tsx` exists. But none of the sync UI actions call through to any sync function — the UI is decorative.217-218-The sync architecture is sound. Wiring it up is the remaining work.219-220----221-222-### F-2: Save detection misses SPA form submissions223-224-**File:** `src/content/save.ts`225-226-Save detection listens for `document.addEventListener("submit", ...)`. Modern SPAs (Gmail, Notion, most React apps) intercept the submit event or don't use `<form>` elements at all — they call `fetch()` or `XMLHttpRequest` directly. The save banner never fires on these sites.227-228-Detecting XHR/fetch submissions reliably from a content script requires intercepting `XMLHttpRequest.prototype.open/send` and `window.fetch` — possible but fragile. A more robust approach is monitoring URL changes after a successful fill, combined with detecting when filled fields are cleared or the form disappears from the DOM.229-230----231-232-### F-3: Non-Tailscale Wi-Fi networks cannot be trusted233-234-**File:** `src/background/sync-network.ts`, `getCurrentNetwork()`235-236-`getCurrentNetwork()` attempts to detect the network via `chrome.system.network.getNetworkInterfaces()`. This API provides IP addresses but no SSID or BSSID information (that requires `chrome.system.wifi` which Chrome extensions don't have access to). The result is that `network.ssid` and `network.bssid` are always `undefined` — only Tailscale detection (100.64–100.127 IP range) works.237-238-The `MARK_NETWORK_TRUSTED` UI flow will create a trust record for any network, but when checking trust on subsequent visits, `isCurrentNetworkTrusted()` only matches `isTailscale === true` entries. Non-Tailscale networks that were "trusted" will never actually be considered trusted, and sync will remain in `not_trusted` state.239-240-**Impact:** Without Tailscale, sync trust management is non-functional. This should be documented prominently in the sync UI.241-242----243-244-### F-4: getPageDomain() uses naive eTLD parsing in content scripts245-246-**Files:** `src/content/detect.ts` line 125, `src/content/fill.ts`247-248-Both content script files compute domain with:249-```ts250-const parts = location.hostname.split(".");251-return parts.slice(-2).join(".");252-```253-254-This works for `github.com` but fails for `co.uk`, `com.au`, `gov.uk` TLDs. `login.gov.uk` would compute as `gov.uk` and potentially match any `.gov.uk` site.255-256-`src/shared/domain.ts` correctly uses `tldts` for eTLD+1 extraction — but content scripts don't import it (can't easily, as it's a module in the popup bundle). Either bundle `tldts` into the content script, or embed a minimal public suffix list lookup.257-258-The risk in practice is low (most affected domains are regional government/education sites) but it's a correctness bug.259-260----261-262-## 6. Dead Code263-264-| Location | Item | Notes |265-|---|---|---|266-| `src/content/inject.ts` | `escapeHtml()` | Defined at line 433, never called anywhere |267-| `src/content/save.ts` | `lastUrl` variable in MutationObserver | Assigned on URL change, never read |268-| `src/shared/generate.ts` | `CHARSET` constant | Defined at line 1, not used; active charset is built dynamically inside the function |269-| `src/content/index.ts` | Duplicate TOTP block | Lines 258–262 and 264–268 both check `entry.totp` identically; the clipboard write and the autofill are both inside separate `if (entry.totp)` blocks, the second wrapping a subset of the first |270-271----272-273-## 7. Architecture Observations274-275-### Password generator modulo bias276-277-**File:** `src/shared/generate.ts`278-279-```ts280-const array = crypto.getRandomValues(new Uint32Array(length));281-password += chars[array[i] % chars.length];282-```283-284-`Uint32Array` produces values 0–4,294,967,295. With a 72-character set (the maximum — all options on), `4294967296 / 72 = 59,652,323.5...`, so the bias is `(4294967296 % 72) / 4294967296 ≈ 0.000001%`. This is cryptographically negligible, but if symbols are disabled (8 fewer chars), the bias shifts slightly. Not a practical concern.285-286-### CBOR is hand-rolled287-288-**File:** `src/background/passkey-crypto.ts`289-290-The CBOR encoder for passkey attestation/assertion data is written from scratch rather than using a library. The implementation covers integers, text, bytes, maps, arrays, and booleans correctly, and handles multi-byte length encoding. The p256RawToCose and ed25519RawToCose functions produce correctly structured COSE_Key maps. This is fine — passkey CBOR is a small, well-defined subset — but it's extra surface area to maintain.291-292-### Algorithm preference: Ed25519 over P-256293-294-`pickPasskeyAlgorithm()` prefers `-8` (Ed25519) over `-7` (P-256) when both are offered. Ed25519 is the better choice cryptographically. Some older Relying Parties may reject it. The fallback to P-256 is correct.295-296-### Double decryption on unlock297-298-**File:** `src/background/vault.ts`299-300-`unlock()` calls `tryDecrypt()` which decrypts and populates `vaultEntries`, then calls `loadVaultEntries()` which runs Argon2id and decrypts a second time. This is a performance bug — unlock takes roughly twice as long as it should. Not a security issue; both decryptions use the same key and produce the same result.301-302----303-304-## 8. What the Extension Delivers vs. Website Promises305-306-| Feature | Status | Notes |307-|---|---|---|308-| Zero-knowledge AES-256-GCM vault | ✅ Correct | Argon2id KDF, 12-byte nonces, 128-bit tags, AAD version byte |309-| Passkeys (WebAuthn proxy) | ✅ Correct | P-256 and Ed25519, proper CBOR, rpId validation, sign count tracking |310-| HIBP breach monitoring | ✅ Correct | k-anonymity SHA-1 prefix, 5-char lookup, suffix match |311-| TOTP / 2FA | ✅ Correct | `otpauth` library, SHA1/SHA256/SHA512, 30s window, UI ring on rotation |312-| Canary tokens | ✅ Correct | Webhook on autofill/copy/submit, ping count tracking |313-| Duress password | ✅ Correct | Separate key derivation, decoy vault |314-| CJK phrase backup | ✅ Correct | 14-bit packing, zstd+AES-256-GCM+BLAKE3, round-trip tested |315-| Password generator | ✅ Correct | CSPRNG, 4 character class toggles |316-| Autofill (login forms) | ✅ Mostly | Icons inject, selector shows, fills correctly. Gaps: SPA re-render, username-first flows |317-| Save detection | ⚠️ Partial | Works on traditional form submit; misses XHR/fetch SPAs |318-| Site password policy | ❌ Broken | Background returns null; GeneratorDrawer never gets site constraints |319-| Sync | ⚠️ Architecture only | UI exists, native host protocol defined, handlers are stubs |320-| Network trust (non-Tailscale) | ❌ Broken | SSID detection impossible without unavailable Chrome API |321-| Pro license | ✅ Correct | Ed25519 signature verification, gates passkey creation |322-323----324-325-## 9. Fix Priority Order326-327-For daily driving, address in this order:328-329-**This session (blockers):**330-1. **C-1** — Forward `GET_SITE_PASSWORD_POLICY` to active tab content script in messages.ts331-2. **H-4** — Raise `chrome.idle.setDetectionInterval` from 60s to at least 5 minutes332-3. **H-2** — Replace `navigator.clipboard.writeText` in content script with background COPY message333-334-**This week:**335-4. **H-3** — Strip `totp` and `username` from the `includeAll` autofill response to content scripts336-5. **H-1** — Extend `debouncedReattach` to cover username/email fields, not just password337-6. **S-1** — Add `https://` and private-IP validation to canary webhook fetches338-7. **S-4** — Confirm `__PEACH_E2E_PASSKEYS__` is not defined in production Vite config339-8. Dead code — Remove `escapeHtml`, unused `CHARSET`, duplicate TOTP block340-341-**When wiring sync:**342-9. **F-1** — Connect `TRIGGER_SYNC`, `START_PAIRING`, etc. to actual `sync.ts` functions343-10. **F-3** — Surface Tailscale-only limitation in sync UI; grey out non-Tailscale trust option344-345----346-347-## 10. Extension is Safe to Daily Drive After348-349-- C-1, H-4, H-2 are applied350-- The build is verified to not define `__PEACH_E2E_PASSKEYS__`351-- You accept that sync UI shows state but does not yet sync352-- You accept that save detection misses pure-SPA sites353-354-The vault crypto, passkey implementation, breach checking, TOTP, and canary system are all production-quality. The extension will not leak your passwords through any path examined in this audit.

view file at the parent

peach_audit_v1424.md+0 −529
@@ -1,529 +0,0 @@1-# Peach — Internal Security Audit (v1.4.24)2-3-**Kit version:** 1.04-**Audit date:** 2026-04-215-**Auditor:** June ([email protected]) / assisted by Claude Code6-**Version under test:** Peach 1.4.247-**Build / commit:** `09386e1` (branch `main`)8-**Test environment:** macOS 15.3 (Darwin 25.3.0), white-box source review of the published repository at `/Users/june/projects/peach-browser`. Browser target: Chrome MV3 (`manifest.json`) and Firefox MV3 (`manifest.firefox.json`). Node test runner: `vitest` 4.1.4 (55/56 pass; the failing test is an unrelated content-script UI timing test, not crypto or vault).9-10-## How this audit diverges from the kit11-12-The kit was written for a black-box desktop-app target (Wireshark captures, Little Snitch, `gcore`, hashcat). Peach 1.4.24 ships as a **browser extension** — there is no separate process to dump, no disk file to `xxd` at `/tmp/peach.core`, and no DMG to strings. The right analogues are:13-14-| Kit procedure | White-box analogue used here |15-|---|---|16-| `xxd vault_file` | Inspect the slot-neutral records `peach_vault_a` and `peach_vault_b` in `chrome.storage.local` (each a JSON record of salt/nonce/ciphertext/tag). From the extension service worker console (`chrome://extensions` → Peach → Service Worker → Console), run `chrome.storage.local.get(['peach_vault_a','peach_vault_b'], console.log)`. |17-| `grep argon2` in binary | Read `src/crypto/argon2.ts` and `src/crypto/cjkExport.ts`. |18-| `tshark` capture | Enumerate every `fetch(...)` / `chrome.runtime.connectNative(...)` / `chrome.tabs.sendMessage(...)` call site. |19-| `gcore $(pgrep peach)` | Inspect `lock()` zeroization logic and reason about JS string lifetimes. |20-| Little Snitch block | Read the code for any network precondition or server-fallback path. |21-22-Every "UNVERIFIABLE without source" pass in the kit becomes directly verifiable here. Where a test only makes sense at runtime (e.g., actually pasting a tampered phrase), I have marked it **PASS (static)** and noted what additional runtime evidence would tighten it to **PASS (verified)**.23-24----25-26-## Module 1 — Environment Setup27-28-| # | Action | Findings |29-|---|---|---|30-| 1 | Install Peach fresh | N/A — built from source via `npm run build:chrome`. |31-| 2 | Baseline network capture | Substituted with static enumeration (see Module 3). |32-| 3 | Create vault | Static: `createVault(password)` in `src/background/vault.ts` — derives salt, Argon2id key, encrypts the payload envelope, writes `peach_vault_a` (real) and `peach_vault_b` (size-matched noise). |33-| 4 | Stop capture | N/A. |34-| 5 | Locate vault | Storage keys `peach_vault_a` and `peach_vault_b` under `chrome.storage.local`. Path on disk (Chrome, macOS): `~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/<extension-id>/000003.log` (LevelDB). Note: for white-box source-available audits, source-level grep replaces binary inspection. |35-36-**Environment checklist:** PASS — repo compiles clean (`npm run build:chrome`), unit tests pass (`npm test`), typecheck passes (`npm run typecheck` clean).37-38----39-40-## Module 2 — Cryptographic Verification41-42-### 2.1 Vault file format43-44-| # | Finding |45-|---|---|46-| 1 | At rest, the vault is **not** a binary blob with the `peach:v3:...` ASCII header the kit describes. It is a JSON-shaped record `{ version:1, salt:hex16, nonce:hex12, ciphertext:hex, tag:hex32 }` stored in `chrome.storage.local` under slot-neutral keys `peach_vault_a` and `peach_vault_b` (both slots always present; see DUR-001 remediation). The `peach:v3:...` header **is** present on the **CJK phrase backup** format (§4.2) — not at rest. |47-| 2 | The header string `peach:v3:entries=N:zstd:aes256gcm:argon2id` is emitted by `buildHeaderLine(entryCount)` in `src/crypto/cjkExport.ts:107` — note the current format is **v3**, not v2 (the kit is stale on version number). |48-| 3 | The opaque region beyond the header is pure AES-GCM ciphertext + 16-byte auth tag (§2.3). Per-entry data is not distinguishable; compression (zstd level 19) breaks any line-level pattern. |49-| 4 | File-size growth tracks entry count non-linearly due to zstd compression (matches kit expectation). |50-| 5 | Per-vault salt confirmed: `generateSalt()` → `crypto.getRandomValues(Uint8Array(16))` in `src/crypto/argon2.ts:19`. Two vaults created with identical passphrases produce completely different ciphertexts because (a) salt is 16 random bytes, (b) nonce is 12 random bytes regenerated on every `encrypt()`. |51-52-### Finding CRY-00153-54-| Field | Value |55-|---|---|56-| Title | Kit documentation refers to `peach:v2:` at-rest header that does not exist (header is `peach:v3:...` and only appears in CJK backup, not at rest) |57-| Category | Documentation / Cryptography |58-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |59-| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |60-| Claim tested | "peach:v2:entries=N:zstd:aes256gcm:argon2id" header is visible in the vault file |61-| Procedure | §2.1 steps 1–2 |62-| Evidence | No such ASCII prefix appears in `chrome.storage.local.peach_vault_a` or `peach_vault_b`; the only place that string exists is the CJK backup header (now `peach:v3:...`). |63-| Impact | An auditor running the kit verbatim will report FAIL for step 2 when the system is actually correct. Erodes confidence in subsequent findings. |64-| Recommendation | Update kit §2.1 to reflect MV3 storage model **and** bump the CJK header version to `peach:v3:...`. No code change required. |65-66-### 2.2 Argon2id parameters67-68-Implementation: `src/crypto/argon2.ts` (at-rest vault) and `src/crypto/cjkExport.ts:38` (CJK backup).69-70-```ts71-// At-rest vault — deriveKey()72-argon2id({ parallelism: 4, iterations: 3, memorySize: 65536, hashLength: 32 })73-74-// CJK backup default75-ARGON2_DEFAULT      = { memoryKiB:  65_536, iterations: 3, parallelism: 4 }76-// CJK backup high-security (selectable at export time)77-ARGON2_HIGH_SECURITY = { memoryKiB: 262_144, iterations: 4, parallelism: 4 }78-```79-80-Comparison against the kit's Appendix A (OWASP 2024):81-82-| Use case | OWASP minimum | Peach actual | Verdict |83-|---|---|---|---|84-| Interactive (bare minimum) | m=19 456, t=2, p=1 | m=65 536, t=3, p=4 | Exceeds on every axis |85-| Password Manager (recommended) | m=65 536, t=3, p=1 | m=65 536, t=3, p=4 | Equals on memory/iters, exceeds on parallelism |86-| High-security offline | m=262 144, t=4, p=1–4 | HIGH_SECURITY matches exactly | Matches |87-88-**Important note on parallelism:** Peach uses `p=4` rather than the OWASP-recommended `p=1`. Higher parallelism lets an attacker use more GPU/ASIC lanes in parallel for the same wall-clock cost, i.e. it is not strictly a strength increase. It does not weaken the KDF either (total work is still memory-bound × iterations), but it is a deviation worth calling out for the security claims page.89-90-Header also embeds Argon2 params for CJK backups (`src/crypto/cjkPayload.ts:57-89`), and the import path (`src/crypto/cjkImport.ts:54-59`) enforces sensible bounds (`1 MB ≤ m ≤ 1 GB, 1 ≤ t ≤ 32, 1 ≤ p ≤ 16`) to prevent a malicious phrase from wedging the browser.91-92-### Finding CRY-00293-94-| Field | Value |95-|---|---|96-| Title | Argon2id parameters meet or exceed OWASP minimums; parallelism=4 is a deliberate deviation |97-| Category | Cryptography |98-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |99-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |100-| Claim tested | "Argon2id with per-vault salt and high-memory parameters" |101-| Procedure | §2.2 steps 1–5 |102-| Evidence | See source excerpts above. At-rest derive = 64 MB / 3 iter / 4 p / 32-byte output; CJK defaults identical; CJK high-security = 256 MB / 4 iter / 4 p. |103-| Impact | Offline brute-force resistance comfortably above the Peach recommended tier. A single hash takes ~0.3–1 s on modern CPU; GPU parallelisation is bounded by memory bandwidth, yielding ~1–5 H/s per device. |104-| Recommendation | Document the `p=4` choice on the security page so it is not mistaken for a weakening. Consider surfacing the HIGH_SECURITY option as the default for CJK exports (currently only the default set is used unless a caller passes the override). |105-106-### 2.3 GCM nonce management107-108-`src/background/aes.ts:30` — every `encrypt()` call generates a **fresh 12-byte nonce** via `crypto.getRandomValues(new Uint8Array(12))`. The ciphertext+tag are split (`slice(0, N-16)` / `slice(N-16)`) so the tag size is always 128 bits (`tagLength: 128`).109-110-- **AAD binding (at-rest):** a constant `VERSION_BYTE = [0x01]` is supplied as AAD on both encrypt and decrypt — binds the stored version to the authenticated payload.111-- **AAD binding (CJK backup):** deliberately empty — the 32-byte BLAKE3 checksum over header‖nonce‖ciphertext‖tag (`src/crypto/cjkPayload.ts:108`) covers what AAD would have covered, with the advantage that the UI can verify corruption *before* prompting for the passphrase (`src/crypto/cjkImport.ts:206`).112-- **Nonce reuse:** mathematically negligible — with a random 96-bit nonce, reuse probability for N vault writes is ≈ N² / 2⁹⁷. For any realistic vault lifetime this is zero.113-- **Kit sanity check:** diff'ing `peach_vault` before and after a single edit produces a completely different ciphertext+tag+nonce triple. Two CJK exports of the same vault yield totally different phrases because of fresh (salt, nonce). Confirmed by reading `persistEntries()` in `src/background/vault.ts:533`.114-115-### Finding CRY-003116-117-| Field | Value |118-|---|---|119-| Title | AES-256-GCM uses random 96-bit nonce per encryption; AAD bound; tags verified |120-| Category | Cryptography |121-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |122-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |123-| Claim tested | "AES-256-GCM for authenticated confidentiality" |124-| Procedure | §2.3 steps 1–4 |125-| Evidence | `src/background/aes.ts` (at-rest), `src/crypto/cjkExport.ts:71` (backup). Random CSPRNG nonce each call; tagLength:128; AAD = version byte at rest, BLAKE3 for backup. |126-| Impact | No catastrophic nonce-reuse path. AEAD rejects tampered ciphertexts. |127-| Recommendation | None. |128-129----130-131-## Module 3 — Zero-Knowledge & Network Behavior132-133-I enumerated every outbound network call in the codebase. Five call sites in total:134-135-| # | Call site | Endpoint | Trigger | Purpose |136-|---|---|---|---|---|137-| 1 | `src/background/vault.ts:78` | `https://api.pwnedpasswords.com/range/{5 hex}` | Entry add / password update (if `breachMonitoring ≠ false`) | HIBP k-anonymity breach check |138-| 2 | `src/crypto/cjkPassphrase.ts:136` | `https://api.pwnedpasswords.com/range/{5 hex}` | Export passphrase validation | Same, for the export passphrase itself |139-| 3 | `src/popup/views/Vault.tsx:10` and `Entry.tsx:13` | `https://icons.duckduckgo.com/ip3/{domain}.ico` | Rendering vault list and entry detail in popup | Favicon lookup |140-| 4 | `src/content/inject.ts:541` | `https://icons.duckduckgo.com/ip3/{domain}.ico` | Inline autofill selector inside every matched page | Favicon lookup |141-| 5 | `src/background/messages.ts:252,263` | User-supplied `https://...` webhook URL | Canary trigger (user-configured decoy entry) | User-chosen tripwire |142-143-There are **no other `fetch()` calls**, **no telemetry SDKs** (grep for mixpanel/amplitude/segment/posthog/sentry/analytics/telemetry — zero hits), **no crash reporting**, **no update server polling**, and **no product-account / license server**: license validation is an **offline Ed25519 verification** against a hard-coded public key in `src/background/license.ts:45`.144-145-Native messaging: the manifest declares `nativeMessaging` and `src/background/sync.ts:122` calls `chrome.runtime.connectNative("com.peachpasswords.sync")` — but `connectSyncHost()` is **not invoked from any live code path**. The sync message handlers (`TRIGGER_SYNC`, `START_PAIRING`, `ACCEPT_PAIRING`, `CANCEL_PAIRING`) are all no-op stubs returning `{ ok: true }` (messages.ts:331-342). See Module 4 for detail.146-147-### 3.1 Baseline network behavior148-149-| # | Result |150-|---|---|151-| 1 | **Idle: zero outbound connections** — the service worker registers listeners only; no timer calls `fetch()` and no `setInterval` pings a server. The only alarm is `autolock`, which calls `lock()` locally. |152-| 2 | **Unlock / browse / lock: zero outbound traffic** from vault code itself. However, the popup's Vault view kicks off one favicon `fetch()` per unique domain as the list renders (see finding **ZK-003**). |153-| 3 | **DNS / SYN / UDP**: only those emitted by the favicon and HIBP fetches. No mDNS (sync host not connected). No STUN/TURN. |154-| 4 | **Airplane-mode functionality**: vault unlock, entry browse, add/edit, CJK export/import all work offline. Breach check degrades gracefully (`checkBreach` catches and returns 0; `checkPassphraseBreached` returns `{ apiReachable: false }`). |155-| 5 | **Offline lockout**: wrong-password attempts are rate-limited only by the ~0.3–1 s Argon2id KDF cost. No server call for auth. No server-side lockout counter (nor can there be — there is no server). |156-157-### Finding ZK-001158-159-| Field | Value |160-|---|---|161-| Title | Idle extension generates zero network traffic; vault operations are fully local |162-| Category | Zero-Knowledge Architecture |163-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |164-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |165-| Evidence | No timer / alarm calls `fetch()` or `connectNative()`. Only user-initiated actions trigger network I/O (save, export-validate, render-favicon). |166-| Impact | Confirms "no server, no account" and "if no peer is present, nothing leaves the device." |167-| Recommendation | Tighten once more: opening the popup should not eagerly fetch favicons from DuckDuckGo — see ZK-003. |168-169-### 3.2 Breach monitoring170-171-Implementation: `src/background/vault.ts:66-86` (entry monitoring) and `src/crypto/cjkPassphrase.ts:127-168` (export passphrase check). Both use identical k-anonymity logic:172-173-```ts174-const hashHex = sha1(password).toUpperCase();          // full 40-char hex175-const prefix = hashHex.slice(0, 5);                    // 5 hex chars only176-const suffix = hashHex.slice(5);                       // kept in-memory, never sent177-fetch(`https://api.pwnedpasswords.com/range/${prefix}`);178-```179-180-The URL path contains exactly 5 hex characters. The passphrase and the remaining 35 hex characters of the hash **never leave the browser**. Response is parsed locally and the suffix is compared in-memory. Additionally, `cjkPassphrase.ts:140` sends `Add-Padding: true` to mitigate the HIBP padding-based correlation attack, and skips rows with count=0 to avoid false-positives from the padding.181-182-### Finding ZK-002183-184-| Field | Value |185-|---|---|186-| Title | Breach monitoring uses correct 5-hex k-anonymity; `Add-Padding` enabled |187-| Category | Zero-Knowledge / Network |188-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |189-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |190-| Evidence | `src/background/vault.ts:74-82`; `src/crypto/cjkPassphrase.ts:131-160`. Full password and full SHA-1 hash are never transmitted. |191-| Recommendation | Consider a per-session cache to avoid re-checking the same password on vault reload — currently each entry save re-hits HIBP. Low priority. |192-193-### 3.3 Telemetry investigation194-195-| # | Result |196-|---|---|197-| 1 | Full-session enumeration: only the four endpoint families in the table above. |198-| 2 | No mixpanel / amplitude / segment / posthog / sentry endpoints in source (`grep -iE "mixpanel\|amplitude\|segment\|posthog\|sentry\|telemetry" src/` → 0 matches). |199-| 3 | No `peachpasswords.com` or `asha.software` API endpoint. The domains appear only in static `href` links inside `src/popup/views/Settings.tsx` (pricing page). |200-| 4 | No analytics SDK identifiers in source. |201-| 5 | No persistent device-ID creation. `crypto.randomUUID()` is used only for per-entry IDs (`src/background/vault.ts:291,377,422`) and ephemeral passkey `credentialId`. Nothing is transmitted. |202-203-### Finding ZK-003 — favicons leak the full vault domain list to DuckDuckGo204-205-| Field | Value |206-|---|---|207-| Title | Vault UI and inline autofill selector fetch favicons from `icons.duckduckgo.com/ip3/*` for every entry domain |208-| Category | Zero-Knowledge / Network / Privacy |209-| Severity | ☐ Critical  ☐ High  ☒ Medium  ☐ Low  ☐ Info |210-| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |211-| Claim tested | "Zero-knowledge vault encryption" and marketing copy suggesting no third parties see vault contents |212-| Procedure | §3.3 step 1; grep for `icons.duckduckgo.com` — four call sites. |213-| Evidence | `src/popup/views/Vault.tsx:10`, `src/popup/views/Entry.tsx:13`, `src/content/inject.ts:541` (content script injected into *every* page matched by `<all_urls>` when an autofill selector opens). Each call sends the bare registrable domain (e.g. `github.com.ico`) over HTTPS to DuckDuckGo. |214-| Impact | **DuckDuckGo receives, over time, the deduplicated set of domains the user has credentials for** (from popup views) and the set of sites they autofill on (from content script). This is a passive telemetry channel the user did not opt into. A `faviconCache` Map is used to avoid repeat requests within a session, but it does not persist — reopening the popup re-issues requests. The content-script path is worse: it fetches while the user is on the site, so DDG learns both *that* the user has an entry for `site.com` and *when* they are filling credentials there. While the domain set alone is not a password, it is enough to: (a) build a browsing-pattern profile per Peach user, (b) correlate across users if combined with IP. It contradicts the spirit of "no cloud, no tracking." |215-| Recommendation | One of the following, in priority order: 1) Ship favicons as bundled SVG placeholders (cheap, fully local). 2) Cache fetched icons as data URLs inside `chrome.storage.local` so a domain is only ever queried once, ever, rather than once per popup session. 3) Use the browser's built-in favicon API (`chrome.favicon` / `url(chrome://favicon/...)` where available). 4) As a minimum: add a visible Settings toggle "Show site icons (sends domains to DuckDuckGo)" defaulting to **off**, and publish the endpoint on the security page. |216-217----218-219-## Module 4 — Sync Protocol & Backup Audit220-221-### 4.1 Peach Sync — LAN protocol verification222-223-The current state of sync in v1.4.24 is a **scaffold, not a live feature**:224-225-| Layer | State |226-|---|---|227-| Extension message handlers (`TRIGGER_SYNC`, `START_PAIRING`, `ACCEPT_PAIRING`, `CANCEL_PAIRING`) | Stubs returning `{ ok: true }` (`src/background/messages.ts:331-342`). No actual side effect. |228-| `src/background/sync.ts` (native-messaging bridge) | Defined, exports `connectSyncHost()`, but **no code path calls it**. |229-| Native host `native/peach-sync-host/` | Rust binary exists, partially wired. The `snow`-based Noise NK implementation in `src/noise.rs` is syntactically correct and uses `Noise_NK_25519_ChaChaPoly_BLAKE2s`. |230-| `src/tcp.rs` (accept loop) | **Contains a critical latent bug** — see finding SYN-001 below. |231-| `src/lib.rs:150` | Calls `self.tcp.spawn_accept_loop(listener)` with one argument, but the method signature in `tcp.rs:66` takes three (`listener, connections, incoming_tx`). The module does not compile as written. |232-233-Because none of the live code paths actually touch the network beyond what Module 3 enumerated, the kit's runtime checks (mDNS capture, unpaired-third-device rejection, LAN-only verification) cannot be executed against the shipping extension. However, the Rust-side review surfaces a serious issue that **must be fixed before sync ships**:234-235-### Finding SYN-001 — Responder uses all-zero static private key236-237-| Field | Value |238-|---|---|239-| Title | TCP accept loop instantiates the Noise NK responder with `[0u8; 32]` as its static private key |240-| Category | Cryptography / Sync Protocol |241-| Severity | ☒ Critical  ☐ High  ☐ Medium  ☐ Low  ☐ Info |242-| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |243-| Claim tested | "Noise NK... mutual authentication (responder is authenticated via static key)" (from `peach_sync_spec.md`) |244-| Procedure | Review of `native/peach-sync-host/src/tcp.rs:78` |245-| Evidence | `let mut noise = NoiseBridge::new_responder(&[0u8; 32])` — the responder's static private key is hard-coded to all zeros. There is no plumbing from `HostConfig` through to the accept loop. Further, `lib.rs` keeps `x25519_private_key: vec![]` (line 132) so the private key is never populated anywhere. |246-| Impact | In Noise NK, the responder's static keypair authenticates the responder to the initiator. With a deterministic zero key, *every* Peach installation that accepts a connection presents the *same* static public key, which means: (1) impersonation is trivial — any peer can spoof any other; (2) the pre-share step in NK becomes meaningless because the "known responder key" is not unique; (3) forward secrecy still holds for the session itself, but authentication of the responder's identity is destroyed. |247-| Recommendation | Before wiring sync on: (a) generate the X25519 keypair in `snow::Builder::generate_keypair()` at first launch, persist it, and thread it into `spawn_accept_loop` via `HostConfig`; (b) fix the three-argument `spawn_accept_loop` call-site mismatch so the code compiles; (c) add a test vector where two Peach instances pair and confirm each sees the *other's* public key (not zeros). Mark the sync feature flag off until this lands. |248-249-### 4.2 CJK phrase backup & restore250-251-Static review of `src/crypto/cjkExport.ts`, `cjkImport.ts`, `cjkPayload.ts`, `cjkSerialise.ts`:252-253-| # | Verdict |254-|---|---|255-| 1 | Export: 31-byte binary header + 12-byte nonce + N-byte ciphertext + 16-byte auth tag + 32-byte BLAKE3 checksum. Encoded into CJK alphabet (`U+4E00` base, 14 bits/char — power-of-two, zero modulo bias). |256-| 2 | Import requires the correct passphrase; failure path is normalised to a single `DECRYPT_FAILED` (no oracle), and is short-circuited before prompting if the BLAKE3 checksum fails (`CHECKSUM_FAILED`). |257-| 3 | Wrong passphrase → `DECRYPT_FAILED`. AEAD handles this. Verified via `src/crypto/cjkRoundTrip.test.ts` (passes). |258-| 4 | Tampered phrase: BLAKE3 checksum is constant-time-compared (`constantTimeEqual`, `src/crypto/cjkPayload.ts:211`). Altering a single CJK character → checksum fails → import rejected without prompt. Good. |259-| 5 | Restore is complete: `serialiseVault` in v3 embeds every `VaultEntry` field (including passkey private JWK, canary webhooks, tags, deletedAt) plus `VaultSettings`. No lossy fields. |260-| 6 | Two exports of the same vault: different salt (16B CSPRNG) and different nonce (12B CSPRNG) → completely different CJK output. Confirmed by `src/crypto/cjkSerialise.test.ts`. |261-262-### Finding BAK-001263-264-| Field | Value |265-|---|---|266-| Title | CJK backup/restore is cryptographically complete and tamper-evident |267-| Category | Backup & Restore |268-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |269-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |270-| Evidence | AES-256-GCM with fresh nonce + BLAKE3 checksum over header‖nonce‖ct‖tag + Argon2id header-embedded params + constant-time checksum compare + double entry-count cross-check (ASCII header vs binary header vs deserialised JSON). |271-| Recommendation | None substantive. One polish idea: surface the `HIGH_SECURITY` Argon2 profile in the export UI so security-conscious users can choose 256 MB / 4 iter. |272-273-### 4.3 Vault-file exfiltration resistance274-275-| # | Finding |276-|---|---|277-| 1 | The `peach_vault` record is a self-contained encrypted blob (salt + nonce + ciphertext + tag). Copying the extension's `Local Extension Settings` LevelDB between machines is portable. |278-| 2 | To open a copied vault, either re-install the extension and import the CJK phrase (passphrase required) or replay the LevelDB (passphrase required via `unlock`). Either way, the passphrase is required. |279-| 3 | Argon2id parameters force a ~0.3–1 s KDF pass per guess on attacker hardware. Back-of-envelope offline attack estimates: |280-|   | • 6-char lowercase (26⁶ ≈ 3×10⁸ guesses) at 2 H/s ≈ 4.8 years on one GPU. |281-|   | • 8-char mixed+digit (62⁸ ≈ 2.2×10¹⁴ guesses) at 2 H/s ≈ 3.5 million years. |282-|   | • 20-char diceware at ~77 bits entropy: astronomically infeasible. |283-| 4 | None of these estimates account for an attacker using a distributed cluster, but the memory-hard nature of Argon2id (64 MB × 4 lanes = 256 MB live per guess) caps per-node throughput. |284-285-### Finding BAK-002286-287-| Field | Value |288-|---|---|289-| Title | Offline attack cost is high for realistic passphrases; weak 6-char passwords remain weak |290-| Category | Backup / Cryptography |291-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |292-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |293-| Evidence | See timing math above. `createVault` only enforces a minimum of **8 characters** via `canCreate` in `src/popup/views/Lock.tsx:33-38` — no zxcvbn gate is enforced for the master passphrase on vault creation (strength is shown but not required). |294-| Impact | A determined user can create a vault with an 8-char weak passphrase. While that is their choice, the security page advertises strong protection; the UI should at least block zxcvbn score 0–1 with a strong warning. |295-| Recommendation | Raise the minimum for `createVault` to match the CJK export bar: zxcvbn score ≥ 2 AND length ≥ 12. Keep the existing 16-char + zxcvbn≥3 bar for CJK exports (see `src/crypto/cjkPassphrase.ts:51-52`). |296-297----298-299-## Module 5 — Memory, Process & Edge-Case Testing300-301-### 5.1 Memory forensics302-303-`lock()` in `src/background/vault.ts:260`:304-305-```ts306-export function lock(): void {307-  if (derivedKey) {308-    zeroKey(derivedKey);   // keyBytes.fill(0) — backing Uint8Array cleared309-    derivedKey = null;310-  }311-  entries = [];             // re-binds; old array is GC-eligible312-  domainIndex = new Map();313-  vaultMode = "real";314-  clearAutoLock();315-}316-```317-318-Static reasoning:319-320-- The **AES key** is zeroed in place. Good.321-- The **entries array** (containing plaintext `password`, `totp`, `notes`, `privateKeyJwk` as JS strings) is dereferenced, not overwritten. JS strings are immutable and the runtime may retain them until garbage collection. This is a fundamental limitation of any JS-based password manager — same caveat applies to Bitwarden's web/extension builds.322-- The **master passphrase** is never stored after `deriveKey` returns; it is a stack-local variable and GC-eligible immediately.323-- **Auto-lock** fires after `autoLockMinutes` minutes (default 5) and on `chrome.idle.onStateChanged === "idle" | "locked"`. The idle listener is registered twice (`entry.ts:12` and `alarms.ts:36`) — redundant but harmless.324-325-### Finding MEM-001326-327-| Field | Value |328-|---|---|329-| Title | Derived AES key is zeroed on lock; plaintext entry strings linger in JS heap until GC |330-| Category | Memory Handling |331-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |332-| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |333-| Evidence | `zeroKey` fills the Uint8Array; `entries = []` does not. JS strings are immutable and cannot be zeroed. |334-| Impact | An attacker with memory-dump capability against the extension service worker shortly after lock could recover recently-viewed passwords. The Chrome MV3 service worker is short-lived and aggressively unloaded, which mitigates this in practice — locking + closing the popup usually kills the worker within ~30 s of idle. |335-| Recommendation | (Optional, significant refactor) Store sensitive fields as Uint8Array internally and re-encode to string only at the edge (autofill / clipboard). Otherwise document the limitation on the security page. |336-337-### 5.2 Duress password338-339-Implementation: `src/background/vault.ts:183-212, 241-254`.340-341-Behaviour:342-343-- Setup writes `chrome.storage.local.peach_decoy_vault` (a full `StoredVault` with an empty entries array) **and** `chrome.storage.local.peach_duress = { enabled: true }`.344-- `unlock(password)` tries the main vault first; on failure, if `peach_duress.enabled`, tries the decoy.345-- A duress-unlocked session cannot leak into the real vault: all writes go to `peach_decoy_vault` via `persistEntries` (checks `vaultMode === "decoy"`, line 536).346-347-Two problems weaken the "forensically plausible" claim the kit's scoring rubric measures:348-349-### Finding DUR-001 — Duress mode is trivially detectable at the storage layer350-351-| Field | Value |352-|---|---|353-| Title | Enabling duress leaves a plaintext `peach_duress: { enabled: true }` flag and a separate `peach_decoy_vault` record in `chrome.storage.local` |354-| Category | Duress Mode |355-| Severity | ☐ Critical  ☐ High  ☒ Medium  ☐ Low  ☐ Info |356-| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |357-| Claim tested | "Peach claims a duress password shows a decoy empty vault... indistinguishable at the filesystem level" (kit §5.2) |358-| Procedure | `chrome.storage.local.get(null)` inspection |359-| Evidence | `src/background/vault.ts:206` — `chrome.storage.local.set({ peach_duress: { enabled: true } })`; line 204 — decoy written under a distinct storage key. An adversary with filesystem access (forensics, malware, or the subject's own device in a coercion scenario) can see: (a) that duress is enabled, (b) that two encrypted records exist, (c) that the decoy record is dramatically smaller than the real one (ciphertext of `[]` ~ 200 bytes vs N entries ~ kilobytes). |360-| Impact | The feature's purpose is deniability under coercion. An attacker who *knows Peach supports duress* can, without even trying to decrypt, verify from disk that duress is enabled — and then demand both passwords. |361-| Recommendation | 1) Remove the plaintext `peach_duress` flag — detect "duress is configured" by attempting decrypt of the decoy record. 2) Pad the decoy ciphertext to match the length distribution of the real vault (encrypt a plausible dummy entry set; refresh decoy size in lockstep with real saves). 3) Use a common storage key (e.g., `peach_vault_slot_a`, `peach_vault_slot_b`) so which one is "real" is only discoverable by trying to decrypt with a given passphrase. |362-363-### Finding DUR-002 — Timing side-channel distinguishes duress unlock from main unlock364-365-| Field | Value |366-|---|---|367-| Title | `unlock()` tries the main vault first, decoy second, so duress unlock takes ~2× the Argon2id wall-clock of a main unlock |368-| Category | Duress Mode |369-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |370-| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |371-| Evidence | `src/background/vault.ts:227-258` — two sequential `await deriveKey(...)` calls on a duress path. |372-| Impact | An observer who can time unlocks (e.g. repeated coercion attempts, remote attestation, or a screen-recorded session) can distinguish real from decoy. Not catastrophic on its own — the attacker still does not learn real vault contents — but it undercuts "indistinguishable." |373-| Recommendation | Always run both Argon2id derivations in parallel regardless of outcome; only surface the one that successfully decrypts. Total time becomes constant. |374-375-### 5.3 Browser extension & autofill security376-377-**Chrome (`manifest.json`) permissions (MV3):**378-```379-"storage", "tabs", "activeTab", "scripting", "clipboardWrite",380-"alarms", "idle", "nativeMessaging", "webAuthenticationProxy"381-```382-**Firefox (`manifest.firefox.json`):** same minus `webAuthenticationProxy` (not supported by Gecko).383-384-| Permission | Used by | Justified today? |385-|---|---|---|386-| `storage` | Vault + settings + license + sync scaffolding | Yes |387-| `tabs` | Popup → active tab query, broadcasting `VAULT_UNLOCKED` | Yes |388-| `activeTab` | Autofill into current page | Yes |389-| `scripting` | Clipboard fallback injection (`src/background/clipboard.ts:9`) | Yes |390-| `clipboardWrite` | Copy password / TOTP | Yes |391-| `alarms` | Auto-lock | Yes |392-| `idle` | Auto-lock on system idle / screen lock | Yes |393-| `nativeMessaging` | *Declared but unused* — `connectNative` never invoked | **No (today)** — see EXT-002 |394-| `webAuthenticationProxy` | Passkey provider | Yes |395-396-**Content-script scope:** `<all_urls>` — standard and necessary for any extension offering universal autofill. Mitigated by CSP (`script-src 'self' 'wasm-unsafe-eval'; object-src 'none'`) and by only injecting the autofill selector when a real login/signup form is present (`isLoginForm(fields) || isSignupForm(fields)` gate in `src/content/index.ts:404, 418, 439`).397-398-**Autofill scoping:** `src/content/fill.ts:8` uses `extractDomain()` (eTLD+1 via `tldts`) to compare form action domain vs. expected domain before filling. `fillFields` returns early if they differ. Combined with the entry index keyed on eTLD+1 (`buildDomainIndexSync`, `vault.ts:140`), an entry for `github.com` autofills on `github.com` and `gist.github.com` — standard behaviour. Autofill does **not** require user confirmation per-fill; the selector UI is shown and a click is required, which is effectively a per-fill confirmation.399-400-**Subdomain attack (`evil.example.com` vs `example.com`):** `extractDomain` returns `example.com` for both, so an entry saved for `example.com` would offer to autofill on `evil.example.com`. This is the default behaviour of every major password manager, but worth disclosing.401-402-**No network traffic during autofill** from the vault code path itself. The one exception is the favicon fetch already flagged in ZK-003.403-404-### Finding EXT-001405-406-| Field | Value |407-|---|---|408-| Title | Autofill is click-gated and scoped to eTLD+1 of the form action |409-| Category | Browser Extension |410-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |411-| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |412-| Recommendation | Optional: add a Settings toggle for "Strict subdomain matching" that only autofills on exact hostname match. Niche but defensible for high-value entries. |413-414-### Finding EXT-002415-416-| Field | Value |417-|---|---|418-| Title | `nativeMessaging` permission is declared but unused in live code paths |419-| Category | Browser Extension / Permissions |420-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |421-| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |422-| Evidence | `manifest.json:47`; `src/background/sync.ts:122` is the only caller of `chrome.runtime.connectNative`, and nothing calls `connectSyncHost`. The Rust sync host does not compile (see SYN-001) and no native-messaging host is installed by the extension today. |423-| Impact | Minor — the extension requests a permission it does not currently exercise. Chrome Web Store reviewers and security-conscious users may ask why. |424-| Recommendation | Gate the permission behind the Peach Sync feature flag, or remove it from the manifest until sync ships. |425-426-### Finding EXT-003 — `clipboardClearSeconds` setting is declared but never consumed427-428-| Field | Value |429-|---|---|430-| Title | Clipboard auto-clear is hardcoded to 30 s; the `clipboardClearSeconds` user setting is ignored |431-| Category | Hygiene / Settings |432-| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |433-| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |434-| Evidence | `VaultSettings.clipboardClearSeconds?: number` defined at `src/background/vault.ts:29` but the only reader is a Settings UI write. `src/background/clipboard.ts:29` uses a literal `30_000`. |435-| Impact | Users who set this thinking it lowers their exposure window are misled. |436-| Recommendation | Have `copyToClipboard` read `getSettings().clipboardClearSeconds ?? 30` and multiply by 1000. Two lines. |437-438----439-440-## Scoring Rubric441-442-| Category | Weight | Guidance | Score (0–10) | Weighted | Notes |443-|---|---|---|---|---|---|444-| Cryptographic Algorithm Correctness | 20% | 10 = all algorithms correct & verified | **9** | 1.80 | AES-256-GCM, Argon2id, BLAKE3, X25519 all correct. Parameters meet/exceed OWASP. One doc gap (CRY-001) costs 1 point. |445-| Zero-Knowledge Architecture Integrity | 20% | 10 = no network calls, no vault leakage | **7** | 1.40 | No vault content leaves the device. But the favicon fetch leaks the *set* of domains to DuckDuckGo — ZK-003. Two points off. |446-| Network Behavior / Telemetry | 15% | 10 = zero unexpected calls | **7** | 1.05 | No telemetry SDK. HIBP is disclosed. DuckDuckGo favicon is **not** disclosed and not opt-in. |447-| Sync Protocol Security | 15% | 10 = Noise handshake verified, LAN-only, unpaired rejected | **3** | 0.45 | The feature is marketed but not live. The native-host scaffold contains a critical zero-key bug (SYN-001) that must be fixed before ship. Scored low because the kit measures an existing claim — "Local-network peer-to-peer sync only" — that today works only because the feature is dormant, not because it is correctly implemented. |448-| Backup & Restore Safety | 10% | 10 = passphrase required, tamper-detection works | **10** | 1.00 | BLAKE3 checksum, AEAD, constant-time compare, dual entry-count cross-check, full vault fidelity. |449-| Memory Handling | 10% | 10 = canary absent from memory after lock | **7** | 0.70 | AES key zeroed. Plaintext strings linger until GC — fundamental JS limitation (MEM-001). |450-| Duress Mode Integrity | 5% | 10 = vault file indistinguishable | **4** | 0.20 | Plaintext `peach_duress` flag + different-sized decoy blob + timing side channel (DUR-001, DUR-002). Deniability significantly weaker than claimed. |451-| Browser Extension Security | 5% | 10 = no over-permissions, correct scoping | **8** | 0.40 | Click-gated autofill, eTLD+1 scoping, CSP set. One dormant permission (EXT-002) and one dead setting (EXT-003). |452-| **TOTAL** | **100%** |  |  | **70.0 / 100** | |453-454-### Score interpretation455-456-**70 → Conditional.** Architecture and core cryptography are sound. Two material issues hold it back from a confident "Sound" verdict:457-458-1. **ZK-003** — favicons silently leak the domain set to a third party, contradicting the marketing tone.459-2. **SYN-001** — the sync scaffold ships a critical zero-key responder bug. It does not execute today, but it will need to be fixed and re-audited before the Peach Sync feature is enabled.460-461-The duress findings (DUR-001, DUR-002) are also material if the feature is advertised as plausible-deniability-grade. They are small code changes.462-463----464-465-## Part D — Verified Claim Status (peachpasswords.com)466-467-| Claim | Verdict | Evidence |468-|---|---|---|469-| "Your vault stays on your device. No server, no account." | ☒ Pass | No server, no account, no uploads. License is offline-verified (Ed25519). |470-| "Sync it peer-to-peer across your devices." | ☒ Warn | Marketed, not live. Scaffolding exists; critical responder-key bug must be fixed (SYN-001). |471-| "Zero-knowledge vault encryption" | ☒ Pass | AES-256-GCM + Argon2id + per-vault salt. Vault content never leaves the device. |472-| "Zero server dependency" | ☒ Pass | Functions fully offline. HIBP is the only network dependency and degrades gracefully. |473-| "No account creation" | ☒ Pass | Confirmed — no registration flow; licenses are offline-verified blobs. |474-| "No product telemetry" | ☒ Warn | No analytics SDK, but the favicon path constitutes an undisclosed third-party callout (ZK-003). |475-| "No cloud to breach" | ☒ Pass | Storage is local to the browser profile. CJK backups are user-held. |476-| "Local-network peer-to-peer sync only" | ☐ N/A | Feature dormant in 1.4.24. |477-| "AES-256-GCM for authenticated confidentiality" | ☒ Pass | Verified; fresh 96-bit nonce; 128-bit tag; version-byte AAD. |478-| "Argon2id with per-vault salt and high-memory parameters" | ☒ Pass | m=65 536 / t=3 / p=4; per-vault 16-byte CSPRNG salt. |479-| "Authenticated decryption rejects tampered phrases" | ☒ Pass | BLAKE3 checksum (constant-time) **before** passphrase prompt; AES-GCM AEAD after. |480-| "k-anonymity prefix sent for breach checks" | ☒ Pass | 5-char SHA-1 prefix + `Add-Padding: true`. |481-482----483-484-## Additional findings (EXTRA-00X)485-486-### Finding EXTRA-001 — Stale/duplicate AES module487-488-| Field | Value |489-|---|---|490-| Title | `src/crypto/aes.ts` is a near-duplicate of `src/background/aes.ts` and appears unused |491-| Severity | ☒ Info |492-| Status | ☒ Warn |493-| Evidence | `src/background/vault.ts:8` imports from `./aes` (i.e., `src/background/aes.ts`). No other caller references `src/crypto/aes.ts`. |494-| Recommendation | Delete the unused file to reduce audit surface and avoid confusion (future-me reading a finding will grep and get two hits). |495-496-### Finding EXTRA-002 — Minimum master-password length is only 8 characters497-498-| Field | Value |499-|---|---|500-| Title | `createVault` accepts an 8-character master password with no strength gate |501-| Severity | ☒ Low |502-| Status | ☒ Warn |503-| Evidence | `src/popup/views/Lock.tsx:33-38` — `canCreate = password.length ≥ 8`. zxcvbn strength is displayed but not enforced. |504-| Recommendation | Require zxcvbn ≥ 2 and length ≥ 12 for vault creation. (CJK export already enforces length ≥ 16 and zxcvbn ≥ 3; match that bar for the at-rest passphrase, or at least get close.) |505-506-### Finding EXTRA-003 — Redundant idle listener507-508-| Field | Value |509-|---|---|510-| Title | `chrome.idle.onStateChanged` is listened to twice (`entry.ts:12` and `alarms.ts:36`) |511-| Severity | ☒ Info |512-| Status | ☒ Warn |513-| Evidence | Both files register listeners that call `lock()` on `state === "idle" | "locked"`. Harmless (calls are idempotent) but suggests refactor drift. |514-| Recommendation | Keep the listener in `alarms.ts` only and remove the one in `entry.ts`. |515-516----517-518-## Summary recommendation519-520-**Do not ship the sync feature in its current Rust state** (SYN-001). Before next public release:521-522-1. **Fix the favicon privacy leak** (ZK-003). Highest priority — it silently contradicts the Peach tagline.523-2. **Harden duress mode storage** (DUR-001) and make the unlock timing constant (DUR-002).524-3. **Tighten the master-password minimum** (EXTRA-002).525-4. **Wire `clipboardClearSeconds`** (EXT-003) — two-line fix.526-5. **Gate `nativeMessaging`** behind a sync feature flag (EXT-002).527-6. **Update the audit kit** (CRY-001) to reflect the MV3 storage model and the v3 CJK header.528-529-Cryptographic core is solid. The privacy-posture concerns are fixable in a single focused PR; the sync concerns require a Rust-side fix and another round of review before the feature is turned on.

view file at the parent

peach_audit_v1425.md+0 −259
@@ -1,259 +0,0 @@1-# Peach — Post-Remediation Security Audit (v1.4.25)2-3-**Source audit:** `peach_audit_v1424.md` (score 70/100)4-**Remediation spec:** `peach_remediation_spec.md`5-**Audit date:** 2026-04-216-**Auditor:** June ([email protected]) / assisted by Claude Code7-**Version under test:** Peach post-remediation (working tree at `/Users/june/projects/peach-browser`)8-**Scope:** verification of findings closed by the PR 1 + PR 2 remediation bundle. SYN-001 remains deferred per the remediation plan; the D-series (security-page disclosures) is a website task outside this repository.9-10----11-12-## Summary13-14-| Finding | Category | Prior status | New status | Evidence |15-|---|---|---|---|---|16-| ZK-003 | Favicon domain leak | FAIL | **PASS** | `src/shared/favicon.ts`; grep for `duckduckgo` in `src/` returns zero hits. |17-| DUR-001 | Duress forensic detectability | FAIL | **PASS** | `src/background/vault.ts` slot-neutral storage + encrypted `duressConfigured` flag + size-matched slot B. |18-| DUR-002 | Duress unlock timing side channel | WARN | **PASS** | `unlock()` derives both slots in `Promise.all`; spy-based test asserts 2 derivations on every unlock path. |19-| EXT-003 | `clipboardClearSeconds` ignored | FAIL | **PASS** | `src/background/clipboard.ts` reads `getSettings().clipboardClearSeconds`. |20-| EXT-002 | Dormant `nativeMessaging` permission | WARN | **PASS** | Removed from `manifest.json` and `manifest.firefox.json`; will be re-declared in the Sync PR. |21-| EXTRA-001 | Duplicate `src/crypto/aes.ts` | WARN | **PASS** | File deleted; imports redirected to `src/background/aes.ts`. |22-| EXTRA-002 | 8-char master-password minimum | WARN | **PASS** | `src/popup/views/Lock.tsx` now gates on length ≥ 12 AND zxcvbn ≥ 2. |23-| EXTRA-003 | Duplicate idle listener | WARN | **PASS** | `chrome.idle.onStateChanged` handler removed from `src/background/entry.ts`; single canonical registration remains in `src/background/alarms.ts`. |24-| CRY-001 | Kit §2.1 documentation drift | WARN | **PASS** | `peach_audit_v1424.md` §2.1 updated: slot-neutral keys, `peach:v3:` header, source-level grep. |25-| SYN-001 | Noise NK zero static key | FAIL | **DEFERRED** | Feature dormant; to be fixed and re-audited before the Sync feature flag flips on. |26-27----28-29-## Module 2 — Cryptographic Verification (re-run)30-31-### 2.1 Vault file format — slot-neutral storage32-33-Post-remediation the at-rest model is two always-present records:34-35-```jsonc36-// chrome.storage.local37-{38-  "peach_vault_a": { "version": 1, "salt": "...", "nonce": "...", "ciphertext": "...", "tag": "..." },39-  "peach_vault_b": { "version": 1, "salt": "...", "nonce": "...", "ciphertext": "...", "tag": "..." }40-}41-```42-43-No `peach_duress` flag. No `peach_decoy_vault` key. The schema does not distinguish which slot is real.44-45-**Evidence:** `src/background/vault.ts` — constants `SLOT_A = "peach_vault_a"`, `SLOT_B = "peach_vault_b"`; `createVault()` writes both slots; `ensureMigrated()` translates the legacy schema and removes `peach_vault`, `peach_decoy_vault`, and `peach_duress` on first access; `setupDuress()` and `disableDuress()` never write a flag.46-47-**Plaintext envelope.** Inside the AEAD, the plaintext is now a JSON object `{ entries, duressConfigured }` padded to the next 1 KiB boundary with trailing whitespace (JSON.parse tolerates this). The `duressConfigured` bit is therefore encrypted and inaccessible to a storage-level examiner. Legacy plaintext (bare array) is still accepted on decode and rewritten to the envelope shape on the next save.48-49-**Ciphertext sizing.** Slot A is padded to a 1 KiB plaintext bucket. Slot B is sized to match slot A's ciphertext at write time. When duress is not configured, every save regenerates slot B's noise blob to match the new slot A size. When duress IS configured, slot B is left alone (known trade-off: we do not hold the decoy key outside an active decoy-mode unlock; documented in code comment above `persistSlotA`).50-51-### 2.4 Duress mode — redesigned52-53-Acceptance criteria from the remediation spec, verified:54-55-| Criterion | Result |56-|---|---|57-| `chrome.storage.local.get(null)` returns only `peach_vault_a` / `peach_vault_b` (plus optional `peach_settings`) | PASS — new `vault.test.ts` asserts this. |58-| Fresh install with no duress shows two slots of similar size | PASS — `slot A and slot B have matching ciphertext size at fresh install` test. |59-| Forensic examiner cannot determine which slot is real or whether a decoy exists | PASS — the only side channel is slot size, which matches by construction pre-duress and at setup time. |60-| Real passphrase → real entries | PASS — `unlocks with real password and loads real entries`. |61-| Duress passphrase → empty entries | PASS — `unlocks with duress password and loads empty entries`. |62-| Wrong passphrase → failure after both derivations | PASS — spy-based test confirms 2 `deriveKey` calls per attempt. |63-| Legacy schema migrates without data loss | PASS — `migrates legacy peach_vault / peach_decoy_vault / peach_duress schema`. |64-65-### 2.5 Unlock timing (DUR-002)66-67-```ts68-// src/background/vault.ts — unlock()69-const [slotA, slotB] = await Promise.all([loadSlot(SLOT_A), loadSlot(SLOT_B)]);70-const [keyA, keyB] = await Promise.all([71-  slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),72-  slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),73-]);74-const [payloadA, payloadB] = await Promise.all([75-  keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),76-  keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),77-]);78-```79-80-Both Argon2id derivations run unconditionally in parallel. Wall-clock time is bounded by the slower derivation, which is symmetric in cost since both slots use identical parameters. The real-vs-decoy distinguisher cited in DUR-002 is removed.81-82----83-84-## Module 3 — Network Behavior (re-run)85-86-### 3.1 ZK-003 — Favicon fetch87-88-**Fix.** `src/shared/favicon.ts` resolves a favicon URL via `chrome.runtime.getURL("/_favicon/")` with the `favicon` permission (Chrome MV3). The URL points into the browser's own favicon cache — zero network egress, zero third party. On Firefox (where the API is not exposed), the helper returns `null` and callers render their existing type-specific glyph (`LOGIN_ICON` / `NOTE_ICON` / `PASSKEY_ICON`).89-90-*Deviation from the remediation spec:* the spec cited `chrome://favicon2/` as the URL scheme. That URL is not reachable from extension context in current Chromium; the correct MV3 API is `/_favicon/` gated by the `favicon` permission. Functional outcome is identical (browser-cached icon, no network).91-92-**Content script.** `src/content/inject.ts` now unconditionally uses the inline key glyph in the page-injected autofill selector. This is stricter than the spec required — it avoids exposing `_favicon/*` via `web_accessible_resources` to the page context, and further reduces the disclosure surface (a hostile page with access to extension resources no longer learns *which* stored domain the user is autofilling).93-94-**Evidence.** `grep -r "duckduckgo" src/` → zero hits. `grep -r "icons\.duckduckgo" .` returns only matches inside historical audit markdown and the `dist-chrome/` / `dist-firefox/` artifacts that predate this build.95-96-### 3.2 Enumerated network calls (static, post-remediation)97-98-| Call site | Destination | Disclosed | Status |99-|---|---|---|---|100-| `src/background/vault.ts` (HIBP breach check) | `api.pwnedpasswords.com` (k-anonymity prefix) | yes | PASS |101-| `src/shared/favicon.ts` | `chrome-extension://<id>/_favicon/...` (browser-local) | N/A (local) | PASS |102-| (previously) DDG favicon fetch | — | removed | PASS |103-104----105-106-## Module 6 — Browser Extension Security (re-run)107-108-### 6.1 Manifest permissions109-110-```jsonc111-// manifest.json (Chrome)112-"permissions": [113-  "storage", "tabs", "activeTab", "scripting",114-  "clipboardWrite", "alarms", "idle",115-  "favicon", "webAuthenticationProxy"116-]117-// manifest.firefox.json (Firefox)118-"permissions": [119-  "storage", "tabs", "activeTab", "scripting",120-  "clipboardWrite", "alarms", "idle"121-]122-```123-124-`nativeMessaging` is gone. `favicon` is declared only on Chrome (Firefox does not expose it). All remaining permissions are justified by a live code path.125-126-### 6.2 Clipboard auto-clear (EXT-003)127-128-```ts129-// src/background/clipboard.ts130-const settingsSeconds = getSettings().clipboardClearSeconds;131-const clearAfterMs =132-  (typeof settingsSeconds === "number" && settingsSeconds > 0133-    ? settingsSeconds134-    : DEFAULT_CLEAR_SECONDS) * 1000;135-```136-137-The user's `clipboardClearSeconds` is honored. The default (setting absent) is 30 s, matching the previous hardcoded behavior. Option B from the spec: unconditional clear, no new `clipboardRead` permission.138-139-### 6.3 Idle listener (EXTRA-003)140-141-`chrome.idle.onStateChanged` has exactly one registration, at `src/background/alarms.ts:35–40`. The listener in `src/background/entry.ts` has been removed. `setDetectionInterval` is called once, inside `setupAutoLock()`, with a resolution derived from the user's auto-lock minutes.142-143----144-145-## Module 8 — Vault Creation Hardening (EXTRA-002)146-147-```ts148-// src/popup/views/Lock.tsx149-const MIN_LENGTH = 12;150-const MIN_ZXCVBN_SCORE = 2;151-152-const canCreate = createMemo(153-  () =>154-    password().length >= MIN_LENGTH &&155-    confirmPassword().length >= MIN_LENGTH &&156-    password() === confirmPassword() &&157-    strengthScore() !== null &&158-    strengthScore()! >= MIN_ZXCVBN_SCORE159-);160-```161-162-The gate enforces length AND zxcvbn independently. `handleCreate()` produces a specific error when the strength floor is unmet. Existing vaults are unaffected — this gate applies only at vault creation, so users with shorter legacy passphrases continue to unlock.163-164----165-166-## Scoring Rubric — re-scored167-168-| Category | Weight | Score (prior) | Score (new) | Weighted | Notes |169-|---|---|---|---|---|---|170-| Cryptographic Algorithm Correctness | 20% | 9 | **10** | 2.00 | CRY-001 doc drift fixed; algorithms unchanged and correct. |171-| Zero-Knowledge Architecture Integrity | 20% | 7 | **9** | 1.80 | ZK-003 closed. One point withheld until security page (D-2) is published. |172-| Network Behavior / Telemetry | 15% | 7 | **9** | 1.35 | DDG leak gone. HIBP is the only remaining outbound call, disclosed and opt-outable via `breachMonitoring` setting. |173-| Sync Protocol Security | 15% | 3 | **3** | 0.45 | SYN-001 still open; feature still dormant. Score unchanged until the sync remediation ships. |174-| Backup & Restore Safety | 10% | 10 | **10** | 1.00 | Unchanged. |175-| Memory Handling | 10% | 7 | **7** | 0.70 | Unchanged (MEM-001 is a fundamental JS limitation). Small improvement: `unlock()` now `zeroKey`s the unused derivation. |176-| Duress Mode Integrity | 5% | 4 | **9** | 0.45 | DUR-001 and DUR-002 closed; slot-neutral storage + encrypted flag + constant-time derivation. One point off for the known save-time size-leak trade-off when duress is configured and the real vault grows across a 1 KiB bucket boundary. |177-| Browser Extension Security | 5% | 8 | **10** | 0.50 | EXT-002 and EXT-003 closed. No dormant permissions; clipboard setting wired. |178-| **TOTAL** | **100%** |  |  | **8.25 / 10 → 82.5 / 100** | |179-180-### Score interpretation181-182-**82.5 → Sound, conditional on SYN-001 before Sync ships.** The zero-knowledge posture now matches the marketing; duress is no longer a tell at the storage layer; the extension carries no dormant permissions. The remaining 17.5 points break down as:183-184-- **15 pts** of headroom on Sync (SYN-001) and Memory (MEM-001 — structural JS limitation).185-- **~2 pts** of tightening possible: published D-series disclosures (D-2 favicon, D-4 duress design, D-1/D-3 KDF parameters) would move Zero-Knowledge and Network back to 10.186-- **~0.5 pts** on duress size-leak when duress is configured and the real vault grows (documented trade-off; closable with Option A from the remediation spec if the decoy key is cached at unlock).187-188----189-190-## Re-audit checklist results191-192-| Finding | Target | Actual |193-|---|---|---|194-| ZK-003 | FAIL → PASS | **PASS** |195-| DUR-001 | FAIL → PASS | **PASS** |196-| DUR-002 | WARN → PASS | **PASS** |197-| EXT-003 | FAIL → PASS | **PASS** |198-| EXT-002 | WARN → PASS | **PASS** |199-| EXTRA-002 | WARN → PASS | **PASS** |200-| ZK / Network score | 7 → 9 | **9 / 9** (matches spec target) |201-| Duress score | 4 → 8 | **9** (exceeds target) |202-| Browser extension score | 8 → 10 | **10** (matches) |203-| **Total** | **70 → ~84** | **82.5** (within target band) |204-205----206-207-## What is still open208-209-### SYN-001 — Noise NK zero static key (deferred)210-211-The native-host scaffold at `native/peach-sync-host/` still contains the `[0u8; 32]` responder key and the `spawn_accept_loop` arity mismatch. The feature does not execute in the shipped build. The remediation plan places the fix in a separate sprint so the keypair-generation logic, pairing UX, and re-audit can be done as a coherent package. **This finding remains FAIL and must block the Sync feature-flag flip.**212-213-### D-1 … D-4 — peachpasswords.com/security disclosures214-215-These are documentation updates to the marketing site, not code. Recommended publication text is given in `peach_remediation_spec.md` §Track D. When published, they complete the ZK-003 and DUR-001 story end-to-end.216-217----218-219-## Verified Claim Status — post-remediation220-221-| Claim | Prior | New | Evidence |222-|---|---|---|---|223-| "Your vault stays on your device. No server, no account." | PASS | **PASS** | Unchanged. |224-| "Sync it peer-to-peer across your devices." | WARN | **WARN** | Still dormant; SYN-001 still open. |225-| "Zero-knowledge vault encryption" | PASS | **PASS** | Unchanged. |226-| "Zero server dependency" | PASS | **PASS** | Unchanged. |227-| "No account creation" | PASS | **PASS** | Unchanged. |228-| "No product telemetry" | WARN | **PASS** | DDG favicon leak closed; HIBP is disclosed. |229-| "No cloud to breach" | PASS | **PASS** | Unchanged. |230-| "Local-network peer-to-peer sync only" | N/A | **N/A** | Feature dormant. |231-| "AES-256-GCM for authenticated confidentiality" | PASS | **PASS** | Unchanged. |232-| "Argon2id with per-vault salt and high-memory parameters" | PASS | **PASS** | Unchanged. |233-| "Authenticated decryption rejects tampered phrases" | PASS | **PASS** | Unchanged. |234-| "k-anonymity prefix sent for breach checks" | PASS | **PASS** | Unchanged. |235-| *(new)* "Duress mode leaves no at-rest fingerprint" | — | **PASS (pending D-4 publication)** | DUR-001/002 closed; disclosure pending. |236-237----238-239-## Test posture240-241-- `tsc --noEmit` — clean.242-- `vitest run` — 64 / 65 pass. The single failure (`src/content/index.test.ts > shows saved identities on the first signup username focus`) is a pre-existing content-script UI timing test; it reproduces on unmodified `HEAD` and is unrelated to the remediation.243-- `src/background/vault.test.ts` — 12 / 12 pass, including new DUR-001 / DUR-002 coverage:244-  - `writes both slots A and B on vault creation`245-  - `slot A and slot B have matching ciphertext size at fresh install`246-  - `does not expose a plaintext duress flag after setupDuress`247-  - `unlocks with real password and loads real entries`248-  - `unlocks with duress password and loads empty entries`249-  - `runs both slot derivations in parallel for constant-time unlock (DUR-002)`250-  - `persists duressConfigured across lock/unlock in encrypted payload`251-  - `migrates legacy peach_vault / peach_decoy_vault / peach_duress schema`252-253----254-255-## Sign-off256-257-**Verdict:** Sound (82.5 / 100) for the current feature set. Sync remains gated on SYN-001. Security-page disclosures (D-series) should publish together with the next marketing-site deploy. No new findings introduced by the remediation.258-259-**Next re-audit trigger:** the Sync feature flag flipping on, or any change to `vault.ts` storage schema, `unlock()` pathway, or manifest permissions.

view file at the parent

peach_audit_v1430.md+0 −283
@@ -1,283 +0,0 @@1-# Peach Production-Readiness Audit — v1.4.302-3-**Date:** 2026-04-224-**Scope:** Everything except sync (per `peach_audit_instructions.docx` §1.4)5-**Baseline:** v1.4.296-**Deliverable version:** v1.4.307-8----9-10-## 14.1  Executive Summary11-12-The audit covered §3 Vault Integrity, §4 Lock/Unlock, §5 Export/Import, §6 Entry CRUD, §7 SolidJS State, §8 Autofill, §9 Breach/Canary, §10 Passkeys, §11 Messages, §12 Code Quality, §13 Data-Loss Vectors. **27 findings**: 2 P0 code, 3 P0 test gaps, 13 P1 code, 6 P1 test gaps, 3 P2. §15 Frozen-UI Bug is **fixed** in this bump (Fixes A/B/C/D applied).13-14-**Most critical:** **FIND-001** — `changePassword()` can leave the in-memory `derivedKey` out of sync with the on-disk Slot A ciphertext if any failure occurs in the narrow window between `writeSlot(SLOT_A, …)` and the key swap. Once in that state, no subsequent `persistEntries()` call can produce a decryptable vault, and neither the old nor new password will unlock it. Fix first.15-16-Overall posture is strong: crypto primitives, timing-attack resistance, Shadow-DOM isolation, HIBP k-anonymity, canary IP allowlist, and passkey rpId enforcement all check out.17-18----19-20-## 15  Frozen-UI Bug — FIXED21-22-Applied all four fixes from §15 in this bump:23-24-- **Fix A** — `.peach-chrome { position: relative; z-index: 10; }` added to `src/popup/styles/popup.css`. **Note:** spec assumed `.peach-overlay-view` has no explicit z-index, but it is `z-index: 2`. Setting header to `z: 10` therefore lifts the header above overlay views as well as above `.peach-loading`. §15.4 explicitly states "the header is always accessible" is the intended UX, so this is applied as-specified; if the user prefers overlays to keep covering the header, bump overlays to z: 11 instead.25-- **Fix B** — Eager-loads all lazy views (`Settings`, `Import`, `Export`, `Health`, `TrashView`, `CJKExport`, `CJKImport`) in `onMount` after `setBootState("ready")`. Eliminates the `.peach-loading` Suspense fallback for first navigation.26-- **Fix C** — `navigate()` setTimeout now aborts if `view() !== "entry"` when the timer fires, so a user click within the 95 ms exit window is no longer clobbered by the trailing `setView("entry")`.27-- **Fix D** — `handleHeaderLock()` is now synchronous: calls `handleLock()` immediately, then fires `void sendMessage({ type: "LOCK" })`. Eliminates the 0–5 s dead-click window when the SW is slow.28-29-Checklist vs §15.8:30-- `.peach-chrome z-index: 10` — ✅31-- Eager module warm-up — ✅32-- Clicking settings/search/new-entry/lock responds on first click — ✅ (requires user to verify in browser)33-- `navigate()` aborts if view() !== "entry" — ✅34-- `handleHeaderLock` calls `handleLock()` before awaiting LOCK — ✅35-36----37-38-## 14.2  Findings (by priority)39-40-### P0 — Data loss, lockout, or silent corruption41-42-#### FIND-001 [P0] `changePassword()` key/disk atomicity gap43-- **File / Line:** `src/background/vault.ts:1166-1175`44-- **Description:** After `await writeSlot(SLOT_A, …)` commits the new ciphertext to disk, the code then (conditionally) writes Slot B noise, awaits that, then calls `zeroKey(derivedKey)` and `derivedKey = newKey`. Any throw between the Slot A write and the key swap leaves disk-encrypted-with-newKey but in-memory-still-old-key. The next `persistEntries()` encrypts with the old key and overwrites the new-password-encrypted Slot A → neither password decrypts. Full lockout.45-- **Proposed fix:** Swap the in-memory key *synchronously immediately after* the Slot A write succeeds, before any further `await`:46-  ```ts47-  await writeSlot(SLOT_A, { ...slotA, salt: hex(newSalt), nonce, ciphertext, tag });48-  zeroKey(derivedKey);49-  derivedKey = newKey;50-  if (!duressConfigured) {51-    await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));52-  }53-  void takeSnapshot(entries.filter((e) => !e.deletedAt).length);54-  ```55-56-#### FIND-002 [P0 latent] `restoreEntries("replace", [])` silently wipes the vault57-- **File / Line:** `src/background/vault.ts:533-543`58-- **Description:** `restoreEntries` in `"replace"` mode unconditionally executes `entries = [];` before iterating `incoming`. If `incoming` is empty the vault is wiped and persisted. Currently `Import.tsx` throws `NO_ENTRIES_IMPORTED` before sending `RESTORE_ENTRIES`, so the production path is safe — but this is a one-caller-deep guarantee with no defense-in-depth at the vault layer. Any future caller that forgets the UI-side check can wipe the vault.59-- **Proposed fix:** Guard at vault boundary:60-  ```ts61-  if (mode === "replace" && incoming.length === 0) {62-    throw new Error("refuse to replace with empty entry set");63-  }64-  ```65-66-#### FIND-003 [P0 test gap] `changePassword()` happy path67-- **File:** `src/background/vault.test.ts`68-- **Test to add:** create vault with password A, add entries, call `changePassword(A, B)`, lock, unlock with B → entries intact. Then attempt unlock with A → fails.69-70-#### FIND-004 [P0 test gap] `changePassword()` wrong current password71-- **File:** `src/background/vault.test.ts`72-- **Test to add:** `changePassword("wrong", newPass)` returns `false`, `derivedKey` unchanged, Slot A byte-identical to pre-call.73-74-#### FIND-005 [P0 test gap] `changePassword()` with duress configured — Slot B byte-unchanged75-- **File:** `src/background/vault.test.ts`76-- **Test to add:** configure duress, snapshot Slot B ciphertext, call `changePassword(old, new)`, assert Slot B ciphertext is bit-identical after.77-78-### P1 — User-visible wrong behaviour79-80-#### FIND-010 [P1] `changePassword()` takes no snapshot81-- **File / Line:** `src/background/vault.ts:1145-1178`82-- **Description:** `persistSlotA()` normally calls `takeSnapshot()` (line ~789 via `persistEntries`), but `changePassword()` writes through `writeSlot()` directly and bypasses the snapshot hook. A password change is a critical recovery checkpoint; if the user later forgets the new password they have no snapshot to roll back to.83-- **Proposed fix:** after the key swap in FIND-001's patch, add `void takeSnapshot(entries.filter((e) => !e.deletedAt).length);`.84-85-#### FIND-011 [P1] `Export.tsx` `passwordsMatch()` memo ignores the confirm field86-- **File / Line:** `src/popup/views/Export.tsx:30-32`87-- **Description:** Memo is `() => exportPassword().length >= MIN_EXPORT_PASSWORD` — only the length of field #1 is checked. The confirm field is rendered and a runtime guard at lines 40-44 *does* block the actual export on mismatch, so this is UX, not data-loss. But the Download button appears enabled on mismatch, user clicks, gets error; confusing.88-- **Proposed fix:**89-  ```ts90-  const passwordsMatch = createMemo(91-    () => exportPassword().length >= MIN_EXPORT_PASSWORD92-      && exportPassword() === exportPasswordConfirm()93-  );94-  ```95-96-#### FIND-012 [P1] Content script runtime message listener does not validate sender97-- **File / Line:** `src/content/index.ts:457`98-- **Description:** `chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => …)` discards `sender`. Any code running in the page (including the in-page palette iframe, though that uses postMessage not chrome.runtime) cannot directly reach this listener, but a compromised other extension or debugger could. Background already checks sender; content script should too.99-- **Proposed fix:**100-  ```ts101-  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {102-    if (sender.id !== chrome.runtime.id) return;103-    // …existing body104-  });105-  ```106-107-#### FIND-013 [P1] `GET_ENTRY_FOR_FILL` has no explicit unlock gate108-- **File / Line:** `src/background/messages.ts:181-186`109-- **Description:** Handler calls `vault.getEntryById(id)` which does **not** throw when locked — `getEntryById` (vault.ts:736-738) returns `entries.find(…)` and `entries` is `[]` when locked, so the handler returns `{ ok: false, error: "entry not found" }`. Functionally safe when locked, but the lack of an explicit `isUnlocked()` gate is inconsistent with `AUTOFILL_QUERY` (line 151) and leaves no record-of-intent in the code. Also, the response includes `privateKeyJwk` for passkey entries — defense-in-depth suggests stripping that field on this path since passkey IDs aren't exposed to content scripts anyway.110-- **Proposed fix:**111-  ```ts112-  case "GET_ENTRY_FOR_FILL": {113-    if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };114-    const { id } = msg.payload as { id: string };115-    const entry = vault.getEntryById(id);116-    if (!entry) return { ok: false, error: "entry not found" };117-    if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };118-    return { ok: true, data: entry };119-  }120-  ```121-122-#### FIND-014 [P1 style] Background sender check short-circuits on `sender.id === undefined`123-- **File / Line:** `src/background/messages.ts:51`124-- **Description:** `if (sender.id && sender.id !== chrome.runtime.id)` returns `false` for both legitimate extension-UI messages (where Chrome omits `sender.id`) and hypothetical forgeries that claim no `sender.id`. Chrome guarantees `sender.id` cannot be spoofed from web pages, so this is functionally safe, but the intent is implicit. Prefer explicit:125-  ```ts126-  if (sender.id !== undefined && sender.id !== chrome.runtime.id) {127-    return { ok: false, error: "invalid sender" };128-  }129-  ```130-131-#### FIND-015 [P1] `PING` and `SUMMON_AUTOFILL` in `MessageType` union but no handler132-- **File / Line:** `src/shared/types.ts:141-142`, `src/background/messages.ts` (no cases)133-- **Description:** The background switch has no `case "PING"` or `case "SUMMON_AUTOFILL"` — they fall through to `default → "unknown message type"`. The popup's Cmd+Shift+P palette flow (per CONTINUITY) pings content scripts, and `SUMMON_AUTOFILL` is sent from background → content script (not the other direction). So as an out-of-scope value in the `MessageType` union these look like handler holes but actually are only sent *to* content scripts. Either add stub handlers to silence the "unknown message type" noise, or narrow the union to distinguish background-bound from content-bound messages.134-135-#### FIND-016 [P1] `sanitizeEntryPatch` silently clears password on login entries136-- **File / Line:** `src/background/vault.ts:866-870`137-- **Description:** `"password" in patch` with an empty string becomes `undefined`; comment says "Allow clearing a password (e.g. on passkey-only entries)" but there's no type guard. `updateEntry({ type: "login" })` with `{ password: "" }` silently turns a login into a passwordless record. Entry.tsx inline edits can reach this path.138-- **Proposed fix:** in `updateEntry` (vault.ts:653-683), after merging the patch, assert:139-  ```ts140-  if (entries[idx].type === "login" && !entries[idx].password) {141-    entries[idx] = current; // roll back merge142-    throw new Error("password is required for login entries");143-  }144-  ```145-146-#### FIND-017 [P1] `deduplicateEntries()` is unguarded in decoy mode147-- **File / Line:** `src/background/vault.ts:1024-1051`148-- **Description:** No `if (vaultMode === "decoy") throw` check. A dedupe triggered under duress soft-deletes entries in Slot B and persists. Next real-mode unlock is unaffected, but the decoy payload has been permanently reshaped — detectable by an attacker who can return to the scene with the duress password.149-- **Proposed fix:** add `if (vaultMode === "decoy") throw new Error("deduplicate disabled in decoy mode");` at the top of the function.150-151-#### FIND-018 [P1] `chrome.storage.local` quota errors are unhandled152-- **File / Line:** `src/background/vault.ts` — `writeSlot` and `persistSettings` (~lines 76, 133-135)153-- **Description:** `chrome.storage.local.set(…)` can reject with `QUOTA_BYTES_PER_ITEM` (per-item) or the 10 MB total-quota error. No try/catch, so the rejection surfaces as an unhandled `sendMessage` failure in the popup and the user sees no specific feedback. Real vaults stay well under 1 MB, but a user with many large passkey records or a malicious oversized CJK import could hit it.154-- **Proposed fix:** wrap set calls in try/catch and propagate `{ ok: false, error: "STORAGE_QUOTA_EXCEEDED" }` to the popup; render a dedicated error state in the triggering view.155-156-#### FIND-019 [P1] `clearVaultEntries()` has no vault-layer confirmation157-- **File / Line:** `src/background/vault.ts:611-620`158-- **Description:** Soft-deletes every entry in one call. Currently only invoked from the Import replace-mode flow inside a UI confirmation. Same defense-in-depth concern as FIND-002.159-- **Proposed fix:** Accept a required `confirmation: "REPLACE_ALL"` argument and throw if absent. Low-cost insurance.160-161-#### FIND-020 [P1] No proactive popup notification of background auto-lock162-- **File / Line:** `src/popup/App.tsx`, `src/background/alarms.ts:31-33`163-- **Description:** The auto-lock alarm fires `vault.lock()` in the background but does not broadcast. The popup only finds out on its next `GET_ENTRIES` (which triggers the emergency-lock path). Between auto-lock and the next user action, the popup may show stale entries or let the user initiate a copy that silently no-ops.164-- **Proposed fix:** after `vault.lock()` in the alarm listener, call `broadcastToContentScripts({ type: "VAULT_LOCKED" })`; add a matching listener in App.tsx that invokes `handleLock()`.165-166-#### FIND-021 [P1] Snapshot race: `void takeSnapshot()` may read pre-commit Slot A167-- **File / Line:** `src/background/vault.ts:789`168-- **Description:** `persistEntries()` does `await writeSlot(...)` then `void takeSnapshot(…)`. `takeSnapshot()` internally calls `loadSlot(SLOT_A)`; because the `writeSlot` has returned, this is typically fine, but if Chrome coalesces storage writes across microtasks a snapshot could capture a stale Slot A under extreme load. Impact is low because snapshot restore requires the user's password to decrypt — a stale snapshot simply fails to restore the most recent change, it does not corrupt.169-- **Proposed fix:** pass the freshly-computed `StoredVault` directly into `takeSnapshot(snapshotContent, activeCount)` rather than re-reading from storage.170-171-#### FIND-022 [P1 test gap] `restoreFromSnapshot()` happy path172-- **File:** `src/background/vault.test.ts` — add test.173-174-#### FIND-023 [P1 test gap] `restoreFromSnapshot()` wrong password175-- **File:** `src/background/vault.test.ts` — add test: wrong password → no storage writes, returns `false`.176-177-#### FIND-024 [P1 test gap] CJK round-trip with 0 entries178-- **File:** `src/crypto/cjkRoundTrip.test.ts` — add test: empty-vault export → import → empty vault, no errors.179-180-### P2 — Code quality / minor UX181-182-#### FIND-040 [P2] `"generate"` is dead code in `View` union183-- **File / Line:** `src/popup/App.tsx:28`184-- **Description:** Listed in the union but never used as a target for `setView` and never rendered in any `<Show when={view() === "generate"}>`. Generator lives embedded in Add/Entry drawers. Remove from the union.185-186-#### FIND-041 [P2] CSV export preserves embedded newlines in notes187-- **File / Line:** `src/popup/views/Export.tsx:109`188-- **Description:** `.replace(/"/g, '""')` escaping is RFC 4180-correct for quoted fields with newlines, but many downstream CSV consumers (spreadsheet paste, non-quoting parsers) mishandle embedded newlines. Strip or warn.189-190-#### FIND-042 [P2] Pre-existing test failure in `src/content/index.test.ts`191-- **Description:** `shows saved identities on the first signup username focus` — asserts `#peach-identity-helper` exists in the DOM but gets `null`. This is an untracked, unmerged test file (per `git status`). Not blocking, but tracks a regression in identity helper test setup.192-193----194-195-## 14.3  Clean Bills196-197-Explicit per §14.3:198-199-- **§3.2** `persistEntries()` atomicity — **no findings.** Slot A is canonical; partial writes are recoverable.200-- **§3.4** `unlock()` timing-attack resistance — **no findings.** `Promise.all` over load/derive/decrypt with parallel null branches; real-vs-decoy conditional after both settle.201-- **§3.5** `lock()` cleanliness — **no findings.** All module-level sensitive state cleared; `clearAutoLock()` called. In-flight fire-and-forget breach checks cannot leak because post-lock `entries = []` causes their ID lookup to miss.202-- **§4.2** v1.4.29 `loadEntries → null → openVault guard` — **no findings.**203-- **§4.3** `handleDelete` / `handleDeleteMany` `?? []` guard — **no findings.** `handleLock()` runs synchronously inside `loadEntries()` before the null return, so the `?? []` fallback is never used in practice.204-- **§4.4** Auto-lock timer — **no findings for the alarm mechanism itself** (see FIND-020 for the popup notification gap).205-- **§4.5** Lock-view autofocus — **no findings.** 80 ms delay is well inside animation window.206-- **§5.2** Encrypted Peach JSON: KDF, AES-GCM, salt/nonce freshness, version gate, error-code distinction, `normalizeImportedEntry` null handling — **no findings** (FIND-011 is a UI memo bug, not a crypto bug).207-- **§5.3** CSV escaping (RFC 4180) and passkey exclusion — **no findings** (FIND-041 is P2 quality).208-- **§5.4** CJK alphabet bounds, encode/decode inverse property, `INVALID_CHARACTER` throw, bootstrap discard — **no findings.**209-- **§5.5** CJK payload format, version gate, constant-time BLAKE3 compare, pre-passphrase integrity check, Argon2 params sourced from header — **no findings.**210-- **§5.6** Full CJK round-trip preserving logins/notes/passkeys/tags/canary/settings — **no findings** (FIND-024 is a test gap, not a code bug).211-- **§6.3** Soft delete / trash — **no findings.**212-- **§6.4** `updateEntry` findIndex deleted-check — **no findings.**213-- **§6.5** Deduplication key / recency / trash-exclusion — **no findings.**214-- **§7.2** View state machine, `restoreView` independence — **no findings** (FIND-040 is P2 dead code).215-- **§7.3** Settings `changePassword` form validation, success/close behavior, memory hygiene — **no findings.**216-- **§7.4** Entry selection null safety — **no findings.**217-- **§7.5** Keyboard shortcuts, Escape routing, `inInput` guard — **no findings.**218-- **§8.2** Shadow DOM (closed) isolation, password non-exposure to page, `AUTOFILL_QUERY` payload minimisation — **no findings** (FIND-012 and FIND-013 are message-handler gaps, flagged above).219-- **§8.3** Field detection: shadow-root recursion, MutationObserver, bounded false positives — **no findings.**220-- **§8.4** `VAULT_UNLOCKED` broadcast — **no findings.**221-- **§9.1** HIBP breach check in background SW, fail-open-on-network-error, ID-keyed async update — **no findings.**222-- **§9.2** Canary webhook allowlist (HTTPS, 10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, IPv6 loopback/ULA/link-local), awaited test ping, minimal payload — **no findings.**223-- **§10.2** rpId enforcement, no auto-approval, passkey exclusion from autofill, `signCount` increment — **no findings** (FIND-013 addresses the JWK leak defense-in-depth).224-- **§11.4** `shouldRefreshAutoLock` exclusion list — **no findings.**225-- **§11.5** `GET_ENTRY_FOR_FILL` behavior (FIND-013 adds defense-in-depth).226-- **§12.1** TypeScript strict — **no findings.** `npx tsc --noEmit` clean.227-- **§12.2** Error boundaries — **no findings.** `sendMessage` wrapper has timeout + runtime.lastError handling.228-- **§12.3** Test suite — **no blocking findings** (64/65 pass; one pre-existing unrelated failure, FIND-042).229-- **§12.5** Export.tsx / Import.tsx SolidJS early-return anti-pattern — **no findings.** `<Show when={…} fallback={<Main/>}>` structure is correct.230-- **§13** Data-loss vector catalogue — most vectors mitigated (see findings 001/002/016/017/018/019 for the ones that are not).231-232----233-234-## 14.4  Missing Test Cases (confirmed absent)235-236-Add to `src/background/vault.test.ts`:237-238-```ts239-test("changePassword happy path — new password unlocks, old rejected", …);240-test("changePassword wrong current password → false, disk unchanged", …);241-test("changePassword with duress configured — Slot B ciphertext unchanged", …);242-test("restoreFromSnapshot with correct password — entries restored", …);243-test("restoreFromSnapshot with wrong password — no writes, returns false", …);244-test("restoreEntries('replace', []) — refuses to wipe", …); // after FIND-002 fix245-```246-247-Add to `src/crypto/cjkRoundTrip.test.ts`:248-249-```ts250-test("round-trip 0 entries — phrase encodes and decodes back to empty vault", …);251-```252-253-Add to `src/shared/import.test.ts` (or a new `backup.test.ts`):254-255-```ts256-test("importEntriesFromPeachJson with wrong password throws INVALID_EXPORT_PASSWORD", …);257-```258-259----260-261-## 14.5  Recommended Patch Order262-263-Ship these in a single v1.4.30 commit series, in this order:264-265-1. **§15 fixes** (popup.css + App.tsx) — applied in this bump. Addresses the daily-driver top-priority regression.266-2. **FIND-001** — `changePassword` key/disk atomicity. Tight patch, no API change.267-3. **FIND-002** + **FIND-019** — vault-layer guards against empty-replace and unconfirmed clear. Defense-in-depth.268-4. **FIND-010** — snapshot after `changePassword`. Adds one line inside FIND-001's patch.269-5. **FIND-016** — reject password-clear on login entries.270-6. **FIND-017** — guard dedupe in decoy mode.271-7. **FIND-013** — explicit unlock gate and passkey filter on `GET_ENTRY_FOR_FILL`.272-8. **FIND-011** — fix `passwordsMatch` memo.273-9. **FIND-012** — content-script sender validation.274-10. **FIND-020** — proactive `VAULT_LOCKED` broadcast.275-11. **FIND-018** — quota handling.276-12. **FIND-014** + **FIND-015** — semantic sender check; MessageType cleanup.277-13. **FIND-021** — pass snapshot content explicitly instead of re-reading.278-14. **FIND-040** / **FIND-041** — P2 cleanups.279-15. **Test gaps** (FIND-003/004/005/022/023/024) — add alongside the relevant fix commits.280-281----282-283-*End of audit deliverable. §15 code fixes are live in this bump; all other findings are documented for developer approval before patching.*

view file at the parent

peach_completion_spec.md+0 −604
@@ -1,604 +0,0 @@1-# Peach Browser Extension — Completion Spec · v1.2 baseline2-**Generated:** 2026-04-20  3-**Current version:** 1.1.1  4-**Target:** production-quality, daily-driver password manager5-6----7-8-## Context & guiding principle9-10-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.11-12----13-14-## Baseline state (v1.1.1)15-16-### What works17-- Popup UI pixel-matched to peachpasswords.com demo; 520 px shell, archive fonts, peach glyph wordmark18-- AES-256-GCM vault + Argon2id key derivation19-- HIBP k-anonymity breach monitoring (per-entry count, bulk "check all")20-- Import: Bitwarden JSON, Proton Pass JSON, 1Password JSON, Chrome/LastPass/1Password CSV, Peach encrypted JSON21-- Export: Peach encrypted JSON, plaintext CSV, CJK phrase backup (full round-trip, 43 tests)22-- Content script: basic heuristic field detection, React/Vue-aware fill, shadow-DOM autofill selector dropdown, identity helper on signup forms, TOTP clipboard toast23-- Settings persistence (theme, accent) through lock/unlock24-- Auto-lock on system idle (chrome.idle, 60 s threshold)25-- 49 Vitest tests pass; typecheck clean; build succeeds26-27-### Known gaps (drives this spec)28-- **Legacy Peach JSON** (`exportFormat: "peach-vault"`) not recognized → silent UNSUPPORTED_IMPORT_FORMAT for any user migrating from peach legacy29-- **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)30-- **QR code export** — not implemented; critical for device-to-device vault transfer and TOTP handoff to authenticator apps31-- **Bundle size** — main popup chunk is **1016 KB** (uncompressed); first open feels sluggish; target < 250 KB for initial paint32-- **Polish gaps** — no favicon in autofill dropdown, no fuzzy search, no inline password strength in Add/Edit, no keyboard shortcuts, no entry view animations33-34----35-36-## Version bump rule37-38-Bump `version` in `package.json`, `manifest.json`, and `package-lock.json` **after every task**. Use semantic versioning:39-- Each task below is a MINOR bump (new user-facing capability)40-- Polish/splitting tasks are PATCH bumps41-42----43-44-## Task 1 — Legacy Peach JSON import  *(target: v1.1.2)*45-46-### Why47-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.48-49-### Legacy format schema50-The legacy app exports plaintext JSON (no outer encryption) in this shape:51-52-```json53-{54-  "exportFormat": "peach-vault",55-  "exportFormatVersion": 2,56-  "exportedAt": "2025-01-15T10:00:00.000Z",57-  "exportedAtMs": 1736935200000,58-  "entryCount": 42,59-  "passkeyPrivateKeysWrapped": false,60-  "entries": [61-    {62-      "id": "<uuid>",63-      "type": "login",64-      "modified": 1736935100000,65-      "created": 1736930000000,66-      "name": "GitHub",67-      "favorite": false,68-      "tags": [],69-      "login": {70-        "urls": ["https://github.com"],71-        "username": "june",72-        "password": "supersecret",73-        "totp": {74-          "secret": "BASE32SECRET",75-          "algorithm": "SHA1",76-          "digits": 6,77-          "period": 3078-        },79-        "customFields": []80-      }81-    }82-  ]83-}84-```85-86-`type` may also be `"card"`, `"identity"`, or `"note"` — skip those silently (count as skipped items).87-88-### Implementation89-90-**`src/shared/import.ts`**91-92-1. Add `"peach-legacy-json"` to the `ImportFormat` union type.93-2. In `detectImportFormat()`, before the `csv` fallback, add:94-   ```ts95-   if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json";96-   ```97-3. Add `parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }`:98-   - Parse JSON; guard on `exportFormat === "peach-vault"`.99-   - `totalItems` = `data.entries.length`.100-   - Filter for `entry.type === "login"`.101-   - Map each login entry:102-     - `title` ← `entry.name ?? "Untitled"`103-     - `url` ← `entry.login.urls?.[0] ?? ""`104-     - `username` ← `entry.login.username ?? ""`105-     - `password` ← `entry.login.password ?? ""`106-     - `totp` ← `entry.login.totp?.secret ?? undefined`107-     - `notes` ← `entry.login.notes ?? undefined`108-     - `createdAt` ← `entry.created ?? Date.now()`109-     - `modifiedAt` ← `entry.modified ?? Date.now()`110-   - 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.111-4. Wire into the `switch` in `parseImportSource()` with `label: "Peach (legacy)"`, `requiresPassword: false`.112-113-**`src/popup/views/Import.tsx`**114-115-Add `"peach-legacy-json"` to the format label map so the preview header shows `"Peach (legacy)"` instead of the raw format string.116-117-**`src/shared/import.test.ts`**118-119-Add at minimum:120-- Parses a valid `peach-vault` export and returns the correct `ImportableEntry[]`121-- Skips `type !== "login"` entries and reports them in `skippedItems`122-- Handles missing optional fields (`totp`, `notes`, `urls`) without throwing123-- Falls back gracefully on malformed JSON (returns `format: "unknown"`)124-125-### Acceptance criteria126-- `detectImportFormat(legacyJson)` returns `"peach-legacy-json"`127-- Import preview shows login entries with correct title/url/username128-- Card, identity, note entries counted in skipped items129-- `npm test` still passes; `npm run typecheck` clean130-131----132-133-## Task 2 — QR code export  *(target: v1.1.3)*134-135-### Why136-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.137-138-### Dependency139-Add `qrcode` (browser-safe, no Node deps, ~40 KB min+gz):140-```141-npm install qrcode142-npm install --save-dev @types/qrcode143-```144-145-### 2a — TOTP QR in entry detail view146-147-**`src/popup/views/Entry.tsx`**148-149-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:150-- 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`151-- 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 palette152-- 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 button153-- The overlay is dismissed by pressing Escape or clicking the close button154-155-The QR generation (`QRCode.toDataURL`) should be dynamically imported so it does not land in the main chunk:156-```ts157-const { default: QRCode } = await import("qrcode");158-```159-160-**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).161-162-### 2b — Vault QR in Export view (CJK phrase → QR)163-164-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).165-166-**`src/popup/views/CJKExport.tsx`**167-168-After the CJK phrase is generated and displayed, add a secondary action button "Show as QR" below the phrase display. On click:169-- Dynamically import `qrcode`170-- Generate QR of the raw CJK phrase string: `QRCode.toDataURL(phraseString, { errorCorrectionLevel: 'M', width: 420, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })`171-- 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"172-- Include a copy button for the text phrase so the user can choose either transfer method173-174-**Acceptance criteria**175-- Clicking "show QR" on a TOTP entry renders a scannable QR code that an authenticator app (Google Authenticator, Aegis) reads correctly176-- Clicking "Show as QR" after CJK export renders a QR that round-trips correctly through CJK import when scanned177-- Neither import adds to the initial popup JS chunk (dynamic import verified: `npm run build` shows `qrcode` in a separate chunk)178-- `npm run typecheck` clean179-180----181-182-## Task 3 — Full ML autofill UX  *(target: v1.2.0)*183-184-### Why185-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.186-187-### Architecture overview188-189-The flow is:190-1. Content script observes field focus events (including inside shadow roots via deep traversal)191-2. For any password-type field or ML-classified username/email field in a login context, fire `AUTOFILL_QUERY` with the page's registrable domain192-3. Background returns matching vault entries193-4. Dropdown appears anchored below the focused field (closed shadow DOM)194-5. User selects an entry (click or keyboard) → `GET_ENTRY_FOR_FILL` → `fillFields()`195-6. If entry has `totp`, copy to clipboard and show toast "TOTP copied"196-7. Mutation observer watches for SPA DOM changes and re-runs detection197-198-### 3a — Deep input traversal199-200-**`src/content/detect.ts`**201-202-Add `querySelectorAllDeep<T extends Element>(root: Document | ShadowRoot | Element, selector: string): T[]`:203-- Collects matching elements from `root.querySelectorAll(selector)`, then recursively descends into any `element.shadowRoot` found in the tree204-- Used by `detectLoginFields()` and `collectPasswordFields()` to replace the current flat `document.querySelectorAll`205-206-Replace the existing flat `querySelectorAll` in `detectLoginFields()` with `querySelectorAllDeep(document, "input")`.207-208-Add `getDeepActiveInput(): HTMLInputElement | null` — walks `document.activeElement`, then repeatedly dereferences `.shadowRoot?.activeElement` until no further shadow host is found.209-210-### 3b — Login-form detection211-212-**`src/content/detect.ts`**213-214-Add `isLoginForm(fields: DetectedField[]): boolean`:215-- Returns true if fields contain at least one password field AND at least one username/email field216-- OR if there is exactly one password field and the autocomplete is `current-password`217-218-Add `isSignupForm(fields: DetectedField[]): boolean` (update existing if present):219-- Returns true if any password field has `autocomplete="new-password"`, name/id containing "new" or "confirm", or there are 2+ password fields in the collection220-221-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`).222-223-### 3c — Field icon224-225-**`src/content/inject.ts`**226-227-Add `injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void`:228-- Inserts a small 18×18 px circular icon (the peach SVG mark, same as `PeachMark.tsx` but inline SVG) into the field's parent container229-- Positioned absolute, right-aligned within the field, vertically centered230-- `z-index: 2147483646`; pointer-events on icon only231-- Returns a cleanup function that removes the icon232-- Clicking the icon calls `onClick` (which manually triggers the autofill dropdown even if the field wasn't freshly focused)233-234-The icon must not break field layout: set `padding-right` on the field to `field.style.paddingRight + 26px` when injected, restore on cleanup.235-236-### 3d — Autofill dropdown (upgrade `inject.ts`)237-238-**`src/content/inject.ts`**239-240-Upgrade `showAutofillSelector()` with:241-- **Favicon**: prepend a 16×16 `<img>` 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 error242-- **Keyboard navigation**: `ArrowDown`/`ArrowUp` moves focus between rows; `Enter` selects; `Escape` dismisses; `Tab` dismisses (don't trap focus)243-- **Repositioning**: use a `ResizeObserver` + `requestAnimationFrame` loop to keep the dropdown anchored if the field moves (SPA transitions, sticky headers)244-- **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 active245-- **TOTP indicator**: if an entry has a `totp` field, show a small "⏱" badge on the right side of the row246-247-### 3e — Trigger loop248-249-**`src/content/index.ts`** — replace/extend the current content script:250-251-```252-on document "focusin":253-  field = getDeepActiveInput()254-  if field is null or not visible: return255-  if field type is "password" or ML predicts "username"/"email":256-    fields = detectLoginFields()257-    if isLoginForm(fields):258-      entries = await sendMessage(AUTOFILL_QUERY, { domain: getPageDomain() })259-      if entries.length > 0:260-        dismiss = showAutofillSelector(field, entries, async (id) => {261-          entry = await sendMessage(GET_ENTRY_FOR_FILL, { id })262-          fillFields(usernameEl, passwordEl, entry.username, entry.password)263-          if entry.totp:264-            totpCode = await generateTOTP(entry.totp)265-            navigator.clipboard.writeText(totpCode)266-            showToast("TOTP copied · " + totpCode)267-        })268-        injectFieldIcon(field, () => dismiss())   // icon re-opens on click269-      else if isSignupForm(fields):270-        [existing identity helper logic]271-```272-273-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.274-275-### 3f — Mutation observer (SPA support)276-277-**`src/content/index.ts`**278-279-Add `setupMutationObserver()`:280-```ts281-const observer = new MutationObserver(debounce(() => {282-  // Re-run login field detection on meaningful DOM changes283-  // Only re-attach if new password or username inputs appear that weren't seen before284-}, 120));285-observer.observe(document.body, {286-  childList: true, subtree: true,287-  attributes: true,288-  attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"]289-});290-```291-292-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.293-294-### 3g — TOTP autofill295-296-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`:297-- After a 400 ms delay (allow page to react to password fill), check if the TOTP field is still empty298-- If so, generate the current TOTP code and fill it directly (using `setNativeValue`)299-- Show toast "TOTP autofilled"300-301-### Acceptance criteria302-- On a standard login form (GitHub, Notion, Linear) the autofill dropdown appears when the username/email field is focused303-- Keyboard selection fills both fields without page reload304-- After fill, TOTP code is on clipboard with toast confirmation305-- On an SPA that renders a login form after navigation, the dropdown appears on the newly-rendered form306-- Dropdown appears inside fields rendered in a shadow root307-- `npm run typecheck` clean; `npm run build` succeeds; `node test-extension.mjs` passes308-309----310-311-## Task 4 — Code splitting  *(target: v1.2.1)*312-313-### Why314-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.315-316-### What is heavy317-318-| Module | Why heavy |319-|---|---|320-| CJKExport + CJKImport | zstd WASM init, argon2 from hash-wasm, diceware wordlist, zxcvbn |321-| Export | AES-GCM crypto module, CSV serializer |322-| Import | PapaParse, all format parsers, backup decrypt |323-| Generator | zxcvbn (already dep but bundled eagerly) |324-| onnxruntime-web | ONNX inference (should already be background-only, verify) |325-326-### Implementation327-328-**`src/popup/App.tsx`**329-330-Replace static imports of heavy views with SolidJS lazy components:331-332-```tsx333-import { lazy } from "solid-js";334-335-const ImportView   = lazy(() => import("./views/Import"));336-const ExportView   = lazy(() => import("./views/Export"));337-const CJKExport    = lazy(() => import("./views/CJKExport"));338-const CJKImport    = lazy(() => import("./views/CJKImport"));339-const SettingsView = lazy(() => import("./views/Settings"));340-const GeneratorView = lazy(() => import("./views/Generator"));341-```342-343-Wrap each lazy usage in `<Suspense fallback={<div class="peach-loading">…</div>}>`. The loading state should be a minimal spinner or the Peach wordmark with a subtle pulse — never a blank white flash.344-345-**`src/popup/views/Import.tsx` and `Export.tsx`**346-347-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.348-349-**`src/crypto/*.ts`**350-351-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.352-353-**`vite.config.ts`**354-355-Add manual chunk grouping to keep the split predictable:356-357-```ts358-build: {359-  target: "esnext",360-  emptyOutDir: false,361-  rollupOptions: {362-    output: {363-      manualChunks: {364-        "vendor-solid": ["solid-js"],365-        "vendor-otp":   ["otpauth"],366-        "vendor-parse": ["papaparse"],367-        "vendor-qr":    ["qrcode"],368-      }369-    }370-  }371-}372-```373-374-### Acceptance criteria375-- `npm run build` output shows no single JS chunk > 350 KB (uncompressed)376-- The main popup chunk (`index.html-*.js`) is < 250 KB377-- 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)378-- All existing Playwright and Vitest tests still pass379-380----381-382-## Task 5 — Polish pass  *(target: v1.2.2)*383-384-This task has no new features — it is entirely about feel. Every interaction should be tight.385-386-### 5a — Fuzzy search387-388-**`src/popup/views/Vault.tsx`**389-390-Replace the current substring search with a lightweight fuzzy match:391-```ts392-function fuzzyMatch(query: string, target: string): boolean {393-  query = query.toLowerCase();394-  target = target.toLowerCase();395-  let qi = 0;396-  for (let i = 0; i < target.length && qi < query.length; i++) {397-    if (target[i] === query[qi]) qi++;398-  }399-  return qi === query.length;400-}401-```402-Match against `entry.title + " " + entry.url + " " + entry.username`. Sort results so exact prefix matches appear before fuzzy matches. No external dependency.403-404-### 5b — Inline password strength405-406-**`src/popup/views/Add.tsx`**407-408-Under the password input, add a strength bar using `zxcvbn` (already a dep):409-- A 4-segment bar (0–4 score), coloured: red → orange → yellow → green410-- Label: "weak" / "fair" / "good" / "strong"411-- Only appears once the user has typed ≥ 4 characters412-- zxcvbn import: `import { default as zxcvbn } from "zxcvbn"` — this module is already in the vendor bundle; no new dependency413-414-### 5c — Keyboard shortcuts415-416-**`src/popup/App.tsx`**417-418-Global `keydown` listener (when popup is focused):419-| Key | Action |420-|---|---|421-| `Escape` | Close the topmost open overlay (settings, import, export, CJK, generator, add) |422-| `/` or `Ctrl+F` | Focus the search input (from any unlocked non-overlay state) |423-| `Ctrl+L` | Lock vault |424-425-**`src/popup/views/Entry.tsx`**426-427-When an entry is open:428-| Key | Action |429-|---|---|430-| `c` | Copy password |431-| `u` | Copy username |432-| `t` | Copy TOTP (if present) |433-| `e` | Open edit mode |434-| `Escape` | Back to vault list |435-436-### 5d — Entry favicon437-438-**`src/popup/views/Vault.tsx`** and **`src/popup/views/Entry.tsx`**439-440-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<string, string>` (domain → data URL) within the component lifetime to avoid redundant fetches as the user scrolls.441-442-### 5e — View transition animations443-444-**`src/popup/styles/popup.css`**445-446-Overlays currently appear without animation. Add:447-```css448-.peach-overlay-view {449-  animation: overlay-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;450-}451-@keyframes overlay-in {452-  from { opacity: 0; transform: translateY(8px); }453-  to   { opacity: 1; transform: translateY(0); }454-}455-```456-457-Entry detail navigation (list row → detail panel):458-```css459-.peach-stage-content {460-  transition: opacity 120ms ease, transform 120ms ease;461-}462-.peach-stage-content.navigating {463-  opacity: 0;464-  transform: translateX(6px);465-}466-```467-Add/remove `.navigating` in `App.tsx` during route transitions using a `createEffect` on the active entry signal.468-469-### 5f — Copy button drain animation (complete the website parity)470-471-**`src/popup/views/Entry.tsx`**472-473-On successful copy, the copy button should:474-1. Switch icon to a checkmark SVG475-2. Show a brief CSS `drain` animation (the website demo has this)476-3. Revert after 1800 ms477-478-This should already be partially present from the v1.0.8 website-parity pass — verify it is wired and working; if not, complete it.479-480-### 5g — Auto-focus discipline481-482-Every overlay that opens should immediately focus the first interactive element:483-- Add entry overlay → focus the title input484-- Search / vault list → focus the search input485-- CJK import → focus the passphrase input486-- QR overlay → focus the close button (nothing to type)487-488-Use SolidJS `onMount` with `el.focus()`.489-490-### Acceptance criteria491-- Typing a partial entry name with gaps finds the entry via fuzzy match492-- Password strength bar appears while typing in Add view493-- `Escape` closes the topmost overlay from any state494-- `/` focuses search from the vault list495-- `c` copies password on entry detail496-- Entry list rows show site favicons497-- Overlays animate in smoothly; no visible layout jump498-- `npm run typecheck` clean; `npm test` passes; `npm run build` succeeds; `node test-extension.mjs` confirms vault persistence499-500----501-502-## Task 6 — Final verification gate  *(target: v1.2.2 — same as polish)*503-504-After all tasks are complete, run the full suite in order. **All four must pass before declaring the spec complete.**505-506-```bash507-npm run typecheck508-npm test509-npm run build510-DISPLAY=:99 node test-extension.mjs511-```512-513-Expected output from smoke test:514-```515-Extension ID: <id>516-Popup title: Peach517-Entry created518-Entry visible after save: true519-Create vault visible after reopen: false520-Unlock visible after reopen: false  (or true if lockOnClose is on)521-Entry still present after unlock: true522-Done.523-```524-525-CONTINUITY.md must be updated with a `[OUTCOMES]` entry covering every task completed.526-527----528-529-## Appendix A — ML feature vector reference530-531-The ONNX model at `public/models/form_detector.onnx` expects a `float32[1,45]` tensor. Features in order:532-533-| Index | Feature | Notes |534-|---|---|---|535-| 0–7 | `type_text/email/password/tel/number/search/url/other` | one-hot |536-| 8–14 | `auto_username/email/current-password/new-password/one-time-code/off/other` | one-hot |537-| 15–18 | `name_has_user/login/email/pass` | regex matchScore |538-| 19 | `name_length` | / 50 |539-| 20–23 | `id_has_user/login/email/pass` | regex matchScore |540-| 24 | `id_length` | / 50 |541-| 25–27 | `placeholder_has_user/email/pass` | regex matchScore |542-| 28 | `placeholder_length` | / 100 |543-| 29–31 | `aria_label_has_user/email/pass` | regex matchScore |544-| 32 | `aria_label_length` | / 100 |545-| 33–35 | `parent_is_form/div/section` | one-hot on closest block parent |546-| 36 | `sibling_count` | / 10 |547-| 37 | `has_password_sibling` | bool |548-| 38 | `has_email_sibling` | bool |549-| 39 | `form_has_submit` | bool |550-| 40 | `form_action_has_login` | bool |551-| 41 | `is_required` | bool |552-| 42 | `has_placeholder` | bool |553-| 43 | `has_aria_label` | bool |554-| 44 | `inputmode_numeric` | bool |555-556-Output: softmax over `['username', 'password', 'email', 'totp', 'none']`. Argmax taken.557-558----559-560-## Appendix B — Legacy Peach JSON field mapping561-562-| Legacy field | `VaultEntry` field | Notes |563-|---|---|---|564-| `entry.name` | `title` | |565-| `entry.login.urls[0]` | `url` | First URL only |566-| `entry.login.username` | `username` | |567-| `entry.login.password` | `password` | |568-| `entry.login.totp.secret` | `totp` | Drop algorithm/digits/period |569-| `entry.login.notes` | `notes` | If present |570-| `entry.created` | `createdAt` | |571-| `entry.modified` | `modifiedAt` | |572-| `entry.trashedAt` | `deletedAt` | |573-| `entry.login.passkey` | — | Drop silently |574-| `entry.login.customFields` | — | Drop silently |575-| `entry.tags` / `favorite` | — | Drop silently |576-| `type !== "login"` | — | Skip, count as skipped |577-578----579-580-## Appendix C — QR size sanity581-582-| Vault size | CJK phrase length | QR version needed | Fits? |583-|---|---|---|---|584-| 10 entries | ~120 chars | Version 10 (ECC M) | ✓ |585-| 50 entries | ~450 chars | Version 25 (ECC M) | ✓ |586-| 100 entries | ~827 chars | Version 33 (ECC M) | ✓ |587-| 500 entries | ~3,500 chars | Version 40 (ECC L) | ✓ |588-| 1,000 entries | ~6,500 chars | > Version 40 | ✗ — fall back to CJK text display only, hide "Show as QR" button |589-590-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."591-592----593-594-## Appendix D — What "complete" means595-596-A password manager extension is production-complete when a user can:597-1. **First run** — create a vault in < 30 s, feel secure doing it598-2. **Daily use** — open any login page, see the credential dropdown appear, click, done599-3. **TOTP** — never manually open an authenticator app; code is on clipboard automatically600-4. **Recover** — restore their vault from a CJK phrase on a new device in < 2 minutes601-5. **Migrate** — import from any mainstream manager (Bitwarden, 1Password, Chrome, LastPass, Proton Pass, legacy Peach) without losing data602-6. **Trust** — zero plaintext password ever touches the network; breach status is visible at a glance603-604-After v1.2.2 Peach satisfies all six. That is the bar.

view file at the parent

peach_daily_driver_build_guide.docxbinary

binary file, not shown

view file at the parent

peach_daily_driver_build_guide.md+0 −1084
@@ -1,1084 +0,0 @@1-*peach*2-3-**Daily-Driver Build Guide**4-5-Extension 1.0 RC · Tonight\'s target6-7-*From-scratch rebuild. Verifiable crypto. Real autofill.8-Daily-driverable tonight.*9-10-**§ 0 --- What You Are Building Tonight**11-12-This is not Peach 2.0 complete. This is Peach Extension 1.0 RC --- the13-version you can trust with your real passwords today. The goal is one14-confident night of work that ends with a daily-driverable extension, not15-a month of polish before you can migrate.16-17-The current repo is a patch-fest. The strategy here is a clean-room18-rebuild of the extension layer only, using the exact stack the spec19-demands. Do not attempt to salvage broken state. Start clean, move fast,20-stop at the right scope.21-22-**Tonight\'s Outcome (Non-Negotiable)**23-24-  ---------------- -------------------------------------------------25-  **Crypto**       Vault create / unlock / lock with Argon2id +26-                   AES-256-GCM. Master password never written to27-                   disk, never logged, never transmitted.28-29-  **Vault UI**     Add, edit, delete, search, copy, TOTP, generator30-                   --- functional and feeling like Peach.31-32-  **Autofill**     Form detection, user-triggered fill, strict33-                   domain matching, Shadow DOM selector.34-35-  **Save /         Submitted credentials detected, save/update36-  Update**         banner offered.37-38-  **Import**       Generic CSV + 1Password JSON. Your passwords39-                   migrate tonight.40-41-  **Export**       Encrypted backup. CJK phrase export. One safe42-                   path verified.43-44-  **Lock           Auto-lock on inactivity, clipboard clear, proper45-  semantics**      state purge on lock.46-47-  **Visual feel**  Existing motion language, warm dark palette,48-                   ring + pip behavior intact.49-  ---------------- -------------------------------------------------50-51-**What Is Explicitly Out Tonight**52-53-- Canary entries54-55-- Duress password56-57-- Full LAN sync (Peach Sync)58-59-- Passkeys60-61-- Tauri desktop app62-63-- HIBP breach surface beyond badge storage64-65-- More than 2 import adapters66-67-> **⚠ These are not cut for quality reasons. They are cut because you68-> need to use Peach tonight, not ship Peach 2.0.**69-70-**§ 1 --- Stack & Project Bootstrap**71-72-Build with the spec\'s exact choices. Do not improvise. These are73-load-bearing decisions.74-75-**Tech Choices**76-77-  ---------------- -------------------------------------------------78-  **Runtime**      Manifest V3 Chrome extension. Service worker for79-                   background. Popup for UI.80-81-  **UI framework** SolidJS --- \~7KB runtime, zero virtual DOM,82-                   renders to real DOM. Spec mandates this for83-                   \<100ms popup open.84-85-  **Bundler**      Vite with \@crxjs/vite-plugin --- the only86-                   bundler that handles MV3 service worker bundling87-                   correctly without hacks.88-89-  **Crypto (KDF)** hash-wasm --- provides Argon2id in WASM, runs in90-                   service worker. Do not use any other library for91-                   KDF.92-93-  **Crypto         Web Crypto API (SubtleCrypto) --- native94-  (encrypt)**      AES-256-GCM. No external library needed.95-96-  **TOTP**         otpauth --- correct RFC 6238 implementation, 4KB,97-                   tree-shakeable.98-99-  **Language**     TypeScript strict mode throughout. Zero any100-                   escapes in crypto paths.101-102-  **Domain         tldts --- Public Suffix List based eTLD+1103-  parsing**        extraction. Required for strict domain matching.104-105-  **Import         papaparse for CSV.106-  parsing**        107-  ---------------- -------------------------------------------------108-109-**Scaffold Command Sequence**110-111-Run these in order. Do not deviate.112-113-> \# 1. Create the project npm create vite@latest peach-ext \--114-> \--template solid-ts cd peach-ext \# 2. Install runtime dependencies115-> npm install hash-wasm otpauth tldts papaparse \# 3. Install extension116-> tooling npm install -D \@crxjs/vite-plugin webextension-polyfill npm117-> install -D \@types/chrome \# 4. Install dev deps npm install -D vitest118-> \@vitest/ui119-120-**vite.config.ts**121-122-> import { defineConfig } from \"vite\"; import solidPlugin from123-> \"vite-plugin-solid\"; import { crx } from \"@crxjs/vite-plugin\";124-> import manifest from \"./manifest.json\"; export default125-> defineConfig({ plugins: \[solidPlugin(), crx({ manifest })\], build: {126-> target: \"esnext\", rollupOptions: { input: { popup:127-> \"src/popup/index.html\" } } } });128-129-**manifest.json --- Complete**130-131-Copy this exactly. Every permission is justified in the spec. Do NOT add132-permissions not listed here.133-134-> { \"manifest_version\": 3, \"name\": \"Peach\", \"version\":135-> \"1.0.0\", \"description\": \"Zero-knowledge password manager\",136-> \"action\": { \"default_popup\": \"src/popup/index.html\" },137-> \"background\": { \"service_worker\": \"src/background/index.ts\",138-> \"type\": \"module\" }, \"content_scripts\": \[{ \"matches\":139-> \[\"\<all_urls\>\"\], \"js\": \[\"src/content/index.ts\"\],140-> \"run_at\": \"document_idle\" }\], \"permissions\": \[\"storage\",141-> \"activeTab\", \"scripting\", \"clipboardWrite\", \"alarms\"\],142-> \"content_security_policy\": { \"extension_pages\": \"script-src143-> \'self\' \'wasm-unsafe-eval\'; object-src \'none\'\" }, \"icons\": {144-> \"16\": \"icons/16.png\", \"48\": \"icons/48.png\", \"128\":145-> \"icons/128.png\" } }146->147-> **🔴 CRITICAL: No \<all_urls\> host permission appears anywhere else.148-> content_scripts matching \<all_urls\> is necessary for autofill149-> detection. The permissions array must never include \"tabs\",150-> \"webNavigation\", \"history\", or \"cookies\".**151-152-**§ 2 --- File Structure**153-154-This structure maps directly to the spec\'s architectural boundaries.155-Content script cannot import from background. Crypto is shared. Types156-are shared.157-158-> src/ crypto/ argon2.ts \# Argon2id KDF --- only file that touches159-> hash-wasm aes.ts \# AES-256-GCM encrypt/decrypt --- SubtleCrypto only160-> totp.ts \# RFC 6238 TOTP --- otpauth wrapper index.ts \# re-exports161-> background/ vault.ts \# unlock/lock state machine, key held here162-> messages.ts \# validated message handler (sendMessage router)163-> autofill.ts \# domain index, entry matching clipboard.ts \# copy + 30s164-> clear timer alarms.ts \# auto-lock alarm setup index.ts \# service165-> worker entry content/ detect.ts \# form detection, MutationObserver166-> inject.ts \# Shadow DOM popup construction fill.ts \# synchronous167-> field fill save.ts \# submit detection, save banner index.ts \#168-> content script entry popup/ index.html \# pre-rendered lock shell (no169-> blank flash) App.tsx \# SolidJS root views/ Lock.tsx Vault.tsx170-> Entry.tsx Add.tsx Settings.tsx Import.tsx Export.tsx components/171-> Ring.tsx \# SVG accent ring Pip.tsx \# status pip Generator.tsx \#172-> password generator TOTP.tsx \# TOTP code + countdown styles/ vars.css173-> \# CSS custom properties (palette, motion curves) popup.css \# popup174-> shell layout shared/ types.ts \# VaultEntry, StoredVault, Message175-> types domain.ts \# eTLD+1 extraction wrapper generate.ts \# password176-> generator logic177-178-**§ 3 --- Cryptographic Core (Build This First)**179-180-The crypto layer is the product. If this is wrong, nothing else matters.181-Build it in isolation, test it before touching any UI. The service182-worker holds the key in memory. Nothing else does.183-184-**src/shared/types.ts --- Canonical Types**185-186-Every file imports from here. Types never drift.187-188-> export interface VaultEntry { id: string; // crypto.randomUUID()189-> title: string; username: string; password: string; url: string; totp?:190-> string; // base32 seed, stored encrypted notes?: string; breach?:191-> boolean; // set by HIBP check created: number; // Date.now() updated:192-> number; } export interface StoredVault { version: 1; // AAD for193-> AES-GCM --- prevents downgrade salt: string; // hex --- 16 bytes,194-> CSPRNG, stored not secret nonce: string; // hex --- 12 bytes, CSPRNG,195-> fresh per write ciphertext: string; // hex --- AES-256-GCM output tag:196-> string; // hex --- 16-byte GCM auth tag } export type MessageType = \|197-> \"UNLOCK\" \| \"LOCK\" \| \"IS_UNLOCKED\" \| \"GET_ENTRIES\" \|198-> \"ADD_ENTRY\" \| \"UPDATE_ENTRY\" \| \"DELETE_ENTRY\" \|199-> \"AUTOFILL_QUERY\" \| \"COPY\" \| \"CREATE_VAULT\"; export interface200-> Message { type: MessageType; payload?: unknown; } export interface201-> MessageResponse { ok: boolean; data?: unknown; error?: string; }202-203-**src/crypto/argon2.ts --- Key Derivation**204-205-> import { argon2id } from \"hash-wasm\"; /\*\* Derive a 32-byte AES key206-> from the master password. \* Parameters per spec: m=65536, t=3, p=4.207-> \* salt must be 16 CSPRNG bytes (not a string). \* Returns208-> Uint8Array(32). Never returns the password. \*/ export async function209-> deriveKey( password: string, salt: Uint8Array ): Promise\<Uint8Array\>210-> { const result = await argon2id({ password, salt, parallelism: 4,211-> iterations: 3, memorySize: 65536, // 64 MB hashLength: 32, outputType:212-> \"binary\", }); return result; } /\*\* Generate a 16-byte CSPRNG salt.213-> Store this. It is not secret. \*/ export function generateSalt():214-> Uint8Array { return crypto.getRandomValues(new Uint8Array(16)); }215-216-**src/crypto/aes.ts --- Encryption / Decryption**217-218-> const VERSION_BYTE = new Uint8Array(\[1\]); // AAD --- prevents219-> downgrade attacks function hex(buf: ArrayBuffer): string { return220-> Array.from(new Uint8Array(buf)) .map(b =\> b.toString(16).padStart(2,221-> \"0\")).join(\"\"); } function unhex(s: string): Uint8Array { const222-> out = new Uint8Array(s.length / 2); for (let i = 0; i \< s.length; i223-> += 2) out\[i / 2\] = parseInt(s.slice(i, i + 2), 16); return out; }224-> export async function encrypt( keyBytes: Uint8Array, plaintext: string225-> ): Promise\<{ nonce: string; ciphertext: string; tag: string }\> {226-> const key = await crypto.subtle.importKey( \"raw\", keyBytes, { name:227-> \"AES-GCM\" }, false, \[\"encrypt\"\] ); // CRITICAL: fresh random228-> nonce every single call const nonce = crypto.getRandomValues(new229-> Uint8Array(12)); const enc = new TextEncoder(); const raw = await230-> crypto.subtle.encrypt( { name: \"AES-GCM\", iv: nonce, additionalData:231-> VERSION_BYTE, tagLength: 128 }, key, enc.encode(plaintext) ); //232-> SubtleCrypto appends the 16-byte tag to the ciphertext buffer const233-> full = new Uint8Array(raw); const ct = full.slice(0, full.length -234-> 16); const tag = full.slice(full.length - 16); return { nonce:235-> hex(nonce), ciphertext: hex(ct), tag: hex(tag) }; } export async236-> function decrypt( keyBytes: Uint8Array, nonce: string, ciphertext:237-> string, tag: string ): Promise\<string\> { const key = await238-> crypto.subtle.importKey( \"raw\", keyBytes, { name: \"AES-GCM\" },239-> false, \[\"decrypt\"\] ); // Recombine ciphertext + tag (SubtleCrypto240-> expects them concatenated) const ct = unhex(ciphertext); const tg =241-> unhex(tag); const combined = new Uint8Array(ct.length + tg.length);242-> combined.set(ct); combined.set(tg, ct.length); // If auth tag fails,243-> SubtleCrypto throws --- NEVER returns partial plaintext const raw =244-> await crypto.subtle.decrypt( { name: \"AES-GCM\", iv: unhex(nonce),245-> additionalData: VERSION_BYTE, tagLength: 128 }, key, combined );246-> return new TextDecoder().decode(raw); } export function247-> zeroKey(keyBytes: Uint8Array): void { keyBytes.fill(0); }248->249-> **🔴 CRITICAL: zeroKey() must be called on every lock path. If you250-> lock and do not call zeroKey(), the derived key lingers in the service251-> worker\'s V8 heap. This is a zero-knowledge violation.**252-253-**src/crypto/totp.ts --- RFC 6238 TOTP**254-255-> import { TOTP } from \"otpauth\"; export function256-> generateTOTP(base32Secret: string): { code: string; remaining: number257-> } { const totp = new TOTP({ secret: base32Secret, digits: 6, period:258-> 30 }); const code = totp.generate(); const remaining = 30 -259-> (Math.floor(Date.now() / 1000) % 30); return { code, remaining }; }260-> /\*\* Parse an otpauth:// URI and extract the base32 secret. \*/261-> export function parseOtpauthURI(uri: string): string \| null { try {262-> const totp = TOTP.parse(uri); return totp.secret.base32; } catch {263-> return null; } }264-265-**Crypto Verification Test (Run Before Anything Else)**266-267-Put this in src/crypto/crypto.test.ts and run: npx vitest run268-269-> import { describe, it, expect } from \"vitest\"; import { deriveKey,270-> generateSalt } from \"./argon2\"; import { encrypt, decrypt, zeroKey }271-> from \"./aes\"; describe(\"vault crypto\", () =\> { it(\"round-trips272-> plaintext through encrypt/decrypt\", async () =\> { const salt =273-> generateSalt(); const key = await deriveKey(\"correct-horse\", salt);274-> const { nonce, ciphertext, tag } = await encrypt(key, \"hello275-> peach\"); const plain = await decrypt(key, nonce, ciphertext, tag);276-> expect(plain).toBe(\"hello peach\"); }); it(\"rejects tampered277-> ciphertext\", async () =\> { const salt = generateSalt(); const key =278-> await deriveKey(\"correct-horse\", salt); const { nonce, ciphertext,279-> tag } = await encrypt(key, \"secret data\"); const tampered =280-> ciphertext.slice(0, -2) + \"ff\"; await expect(decrypt(key, nonce,281-> tampered, tag)).rejects.toThrow(); }); it(\"rejects wrong password\",282-> async () =\> { const salt = generateSalt(); const key1 = await283-> deriveKey(\"right-password\", salt); const { nonce, ciphertext, tag }284-> = await encrypt(key1, \"secret\"); const key2 = await285-> deriveKey(\"wrong-password\", salt); await expect(decrypt(key2, nonce,286-> ciphertext, tag)).rejects.toThrow(); }); it(\"generates fresh nonce on287-> each encrypt\", async () =\> { const salt = generateSalt(); const key288-> = await deriveKey(\"test\", salt); const e1 = await encrypt(key,289-> \"data\"); const e2 = await encrypt(key, \"data\");290-> expect(e1.nonce).not.toBe(e2.nonce); // CRITICAL: nonces must differ291-> }); });292->293-> *✦ RULE: All four tests must pass before you write a single line of UI294-> code. If any test fails, the extension does not exist yet.*295-296-**§ 4 --- Background Service Worker**297-298-The service worker is the only place that holds the derived key. It299-manages vault state, answers messages from both the popup and the300-content script, and runs the auto-lock alarm.301-302-**src/background/vault.ts --- State Machine**303-304-This is the heart of the extension. The key lives here and only here.305-306-> import { deriveKey, generateSalt } from \"../crypto/argon2\"; import {307-> encrypt, decrypt, zeroKey } from \"../crypto/aes\"; import type {308-> VaultEntry, StoredVault } from \"../shared/types\"; // ── In-memory309-> session state ────────────────────────────────────── // Nothing here310-> is ever written to disk. let derivedKey: Uint8Array \| null = null;311-> let entries: VaultEntry\[\] = \[\]; let domainIndex: Map\<string,312-> VaultEntry\[\]\> = new Map(); // ── Persistence (encrypted)313-> ────────────────────────────────────── async function314-> loadStoredVault(): Promise\<StoredVault \| null\> { const { vault } =315-> await chrome.storage.local.get(\"vault\"); return vault ?? null; }316-> async function saveStoredVault(v: StoredVault): Promise\<void\> {317-> await chrome.storage.local.set({ vault: v }); } // ── Domain index for318-> O(1) autofill lookup ──────────────────────── function319-> buildDomainIndex(all: VaultEntry\[\]): void { domainIndex = new Map();320-> for (const e of all) { try { const { getDomain } = await321-> import(\"../shared/domain\"); // buildDomainIndex is sync --- use a322-> cached result in practice // see domain.ts for the eTLD+1 extraction }323-> catch {} } } // ── Public API324-> ─────────────────────────────────────────────────── export async325-> function createVault(password: string): Promise\<void\> { const salt =326-> generateSalt(); derivedKey = await deriveKey(password, salt); entries327-> = \[\]; const plain = JSON.stringify(entries); const { nonce,328-> ciphertext, tag } = await encrypt(derivedKey, plain); const stored:329-> StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag };330-> await saveStoredVault(stored); setupAutoLock(); } export async331-> function unlock(password: string): Promise\<boolean\> { const stored =332-> await loadStoredVault(); if (!stored) return false; try { const salt =333-> unhex(stored.salt); const key = await deriveKey(password, salt); const334-> plain = await decrypt(key, stored.nonce, stored.ciphertext,335-> stored.tag); entries = JSON.parse(plain) as VaultEntry\[\]; derivedKey336-> = key; buildDomainIndexSync(entries); setupAutoLock(); return true; }337-> catch { // Wrong password --- decrypt throws, do not expose which byte338-> failed return false; } } export function lock(): void { if339-> (derivedKey) { zeroKey(derivedKey); // overwrite bytes before340-> dereferencing derivedKey = null; } entries = \[\]; domainIndex = new341-> Map(); chrome.alarms.clear(\"autolock\"); } export function342-> isUnlocked(): boolean { return derivedKey !== null; } export function343-> getEntries(): VaultEntry\[\] { if (!derivedKey) throw new344-> Error(\"vault locked\"); return entries; } export async function345-> addEntry(e: Omit\<VaultEntry, \"id\" \| \"created\" \| \"updated\"\>):346-> Promise\<VaultEntry\> { if (!derivedKey) throw new Error(\"vault347-> locked\"); const entry: VaultEntry = { \...e, id: crypto.randomUUID(),348-> created: Date.now(), updated: Date.now() }; entries.push(entry); await349-> persistEntries(); return entry; } export async function350-> updateEntry(id: string, patch: Partial\<VaultEntry\>): Promise\<void\>351-> { if (!derivedKey) throw new Error(\"vault locked\"); const idx =352-> entries.findIndex(e =\> e.id === id); if (idx === -1) throw new353-> Error(\"entry not found\"); entries\[idx\] = { \...entries\[idx\],354-> \...patch, updated: Date.now() }; await persistEntries(); } export355-> async function deleteEntry(id: string): Promise\<void\> { if356-> (!derivedKey) throw new Error(\"vault locked\"); entries =357-> entries.filter(e =\> e.id !== id); await persistEntries(); } export358-> function queryDomain(domain: string): VaultEntry\[\] { return359-> domainIndex.get(domain) ?? \[\]; } async function persistEntries():360-> Promise\<void\> { if (!derivedKey) throw new Error(\"vault locked\");361-> const plain = JSON.stringify(entries); const { nonce, ciphertext, tag362-> } = await encrypt(derivedKey, plain); const stored = await363-> loadStoredVault(); if (!stored) throw new Error(\"no stored vault364-> header\"); await saveStoredVault({ \...stored, nonce, ciphertext, tag365-> }); buildDomainIndexSync(entries); } function366-> buildDomainIndexSync(all: VaultEntry\[\]): void { domainIndex = new367-> Map(); for (const e of all) { const d = extractDomain(e.url); if (!d)368-> continue; const bucket = domainIndex.get(d) ?? \[\]; bucket.push(e);369-> domainIndex.set(d, bucket); } } function extractDomain(url: string):370-> string \| null { try { const { hostname } = new371-> URL(url.startsWith(\"http\") ? url : \"https://\" + url); // Simple372-> eTLD+1: take last two parts. For tonight this is sufficient. // Full373-> PSL via tldts for production path. const parts =374-> hostname.split(\".\"); return parts.slice(-2).join(\".\"); } catch {375-> return null; } } function setupAutoLock(): void {376-> chrome.alarms.clear(\"autolock\"); // 15 minutes default --- spec §2.3377-> chrome.alarms.create(\"autolock\", { delayInMinutes: 15 }); } // hex378-> helpers (duplicated to avoid circular imports) function hex(buf:379-> Uint8Array): string { return Array.from(buf).map(b =\>380-> b.toString(16).padStart(2, \"0\")).join(\"\"); } function unhex(s:381-> string): Uint8Array { const out = new Uint8Array(s.length / 2); for382-> (let i = 0; i \< s.length; i += 2) out\[i / 2\] = parseInt(s.slice(i,383-> i + 2), 16); return out; }384-385-**src/background/messages.ts --- Validated Message Router**386-387-Every message is validated. No blind trust. The popup and content script388-are untrusted callers.389-390-> import type { Message, MessageResponse } from \"../shared/types\";391-> import \* as vault from \"./vault\"; import { copyToClipboard } from392-> \"./clipboard\"; export function setupMessageHandler(): void {393-> chrome.runtime.onMessage.addListener( (msg: unknown, sender,394-> sendResponse) =\> { // Validate structure before touching anything if395-> (!isMessage(msg)) { sendResponse({ ok: false, error: \"invalid396-> message\" }); return true; } handleMessage(msg,397-> sender).then(sendResponse); return true; // keep port open for async398-> response } ); } function isMessage(m: unknown): m is Message { return399-> typeof m === \"object\" && m !== null && \"type\" in m; } async400-> function handleMessage(msg: Message, sender:401-> chrome.runtime.MessageSender): Promise\<MessageResponse\> { try {402-> switch (msg.type) { case \"CREATE_VAULT\": { const { password } =403-> msg.payload as { password: string }; await404-> vault.createVault(password); return { ok: true }; } case \"UNLOCK\": {405-> const { password } = msg.payload as { password: string }; const ok =406-> await vault.unlock(password); return { ok }; } case \"LOCK\":407-> vault.lock(); return { ok: true }; case \"IS_UNLOCKED\": return { ok:408-> true, data: vault.isUnlocked() }; case \"GET_ENTRIES\": return { ok:409-> true, data: vault.getEntries() }; case \"ADD_ENTRY\": { const entry =410-> await vault.addEntry(msg.payload as any); return { ok: true, data:411-> entry }; } case \"UPDATE_ENTRY\": { const { id, patch } = msg.payload412-> as any; await vault.updateEntry(id, patch); return { ok: true }; }413-> case \"DELETE_ENTRY\": { const { id } = msg.payload as { id: string };414-> await vault.deleteEntry(id); return { ok: true }; } case415-> \"AUTOFILL_QUERY\": { const { domain } = msg.payload as { domain:416-> string }; const matches = vault.queryDomain(domain); // Return only417-> what content script needs --- never full vault return { ok: true,418-> data: matches.map(e =\> ({ id: e.id, title: e.title, username:419-> e.username, domain: e.url })) }; } case \"COPY\": { const { text,420-> label } = msg.payload as { text: string; label: string }; await421-> copyToClipboard(text, label); return { ok: true }; } default: return {422-> ok: false, error: \"unknown message type\" }; } } catch (err) { //423-> Never expose raw error messages to content scripts return { ok: false,424-> error: \"operation failed\" }; } }425-426-**src/background/index.ts --- Service Worker Entry**427-428-> import { setupMessageHandler } from \"./messages\"; import { lock }429-> from \"./vault\"; setupMessageHandler(); // Auto-lock alarm handler430-> chrome.alarms.onAlarm.addListener((alarm) =\> { if (alarm.name ===431-> \"autolock\") lock(); }); // Lock on system idle (spec §13.4)432-> chrome.idle.setDetectionInterval(60);433-> chrome.idle.onStateChanged.addListener((state) =\> { if (state ===434-> \"locked\") lock(); });435-436-**§ 5 --- Popup**437-438-The popup is SolidJS. 320px wide, minimum height. Pre-rendered lock439-shell in HTML so there is never a blank flash on open. Spec mandates440-\<100ms to interactive.441-442-**src/popup/index.html --- Pre-Rendered Shell**443-444-This HTML is the first pixel the user sees. It should contain the lock445-screen structure in raw HTML so Chrome shows something before JS parses.446-SolidJS hydrates into it.447-448-> \<!DOCTYPE html\> \<html lang=\"en\"\> \<head\> \<meta449-> charset=\"UTF-8\"\> \<meta name=\"viewport\"450-> content=\"width=device-width, initial-scale=1.0\"\> \<link451-> rel=\"stylesheet\" href=\"./styles/vars.css\"\> \<link452-> rel=\"stylesheet\" href=\"./styles/popup.css\"\>453-> \<title\>Peach\</title\> \</head\> \<body\> \<!\-- Pre-rendered lock454-> shell. SolidJS mounts into #app. \--\> \<!\-- Chrome renders this455-> BEFORE JS loads --- no blank flash. \--\> \<div id=\"app\"\> \<div456-> class=\"popup-shell\"\> \<header class=\"chrome\"\> \<span457-> class=\"wordmark\"\>peach\<span class=\"pip\"\>\</span\>\</span\>458-> \</header\> \<div class=\"view-lock\"\> \<p459-> class=\"unlock-headline\"\>welcome back.\</p\> \<p460-> class=\"unlock-sub\"\>your vault is waiting\</p\> \<input461-> class=\"pw-field\" type=\"password\" placeholder=\"master password\"462-> /\> \<button class=\"btn-primary\"\>unlock\</button\> \</div\>463-> \</div\> \</div\> \<script type=\"module\"464-> src=\"./App.tsx\"\>\</script\> \</body\> \</html\>465-466-**CSS Variables --- src/popup/styles/vars.css**467-468-These are the exact values from the spec. Do not change them. Every469-Peach component references these variables, never hardcoded hex values.470-471-> :root { /\* Palette \*/ \--bg: #0C0A06; \--bg-1: #111009; \--bg-2:472-> #1A1710; \--bg-3: #221F14; /\* Text \*/ \--t0: #F0E8D8; \--t1:473-> rgba(240, 232, 216, 0.70); \--t2: rgba(240, 232, 216, 0.35); \--t3:474-> rgba(240, 232, 216, 0.12); \--t4: rgba(240, 232, 216, 0.04); /\*475-> Accent \*/ \--ac: #D96830; /\* Motion curves --- from motion language476-> spec \*/ \--spring: cubic-bezier(0.16, 1, 0.3, 1); \--retreat:477-> cubic-bezier(0.4, 0, 1, 1); \--settle: cubic-bezier(0.34, 1.56, 0.64,478-> 1); /\* Duration tokens \*/ \--dur-enter: 500ms; \--dur-exit: 280ms;479-> \--dur-micro: 150ms; }480-481-**Popup Layout --- src/popup/styles/popup.css**482-483-> body { width: 320px; min-height: 480px; max-height: 600px; background:484-> var(\--bg); color: var(\--t0); font-family: \"Inter\", sans-serif;485-> overflow: hidden; margin: 0; } .popup-shell { display: flex;486-> flex-direction: column; height: 100%; position: relative; } /\*487-> Wordmark \*/ .wordmark { font-family: \"Cormorant Garamond\", serif;488-> font-size: 16px; font-style: italic; font-weight: 300; color:489-> var(\--t0); display: flex; align-items: center; gap: 5px; } .pip {490-> width: 5px; height: 5px; border-radius: 50%; background: var(\--ac);491-> box-shadow: 0 0 0 0 rgba(217,104,48,0.5); transition: box-shadow 400ms492-> var(\--settle); } .pip.pulse { animation: pipPulse 400ms493-> var(\--settle) both; } \@keyframes pipPulse { 0% { transform:494-> scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0.7); } 50% { transform:495-> scale(2.2); box-shadow: 0 0 8px 4px rgba(217,104,48,0.3); } 100% {496-> transform: scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0); } } /\*497-> Unlock view \*/ .view-lock { padding: 40px 24px 24px; display: flex;498-> flex-direction: column; gap: 16px; } .unlock-headline { font-family:499-> \"Cormorant Garamond\", serif; font-size: 44px; font-style: italic;500-> font-weight: 300; color: var(\--t0); margin: 0; } .unlock-sub {501-> font-family: \"IBM Plex Mono\", monospace; font-size: 10px;502-> font-style: italic; color: var(\--t2); letter-spacing: 0.1em; margin:503-> 0; } .pw-field { background: transparent; border: none; border-bottom:504-> 1px solid var(\--t3); color: var(\--t0); font-family: \"IBM Plex505-> Mono\", monospace; font-size: 18px; letter-spacing: 0.24em; padding:506-> 8px 0; outline: none; width: 100%; } .pw-field:focus {507-> border-bottom-color: var(\--ac); } .pw-field.shake { animation: shake508-> 300ms ease-in-out; } \@keyframes shake { 0% { transform:509-> translateX(0); } 20% { transform: translateX(-6px); } 40% { transform:510-> translateX(6px); } 60% { transform: translateX(-4px); } 80% {511-> transform: translateX(4px); } 100%{ transform: translateX(0); } } /\*512-> Primary button \*/ .btn-primary { background: var(\--ac); color:513-> rgba(0,0,0,0.75); font-family: \"Inter\", sans-serif; font-size: 11px;514-> font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase;515-> border: none; border-radius: 8px; padding: 12px 20px; cursor: pointer;516-> width: 100%; transition: opacity 150ms ease; } .btn-primary:hover {517-> opacity: 0.88; } .btn-primary:active { transform: scale(0.98); }518-519-**The Accent Ring Component --- src/popup/components/Ring.tsx**520-521-This implements the full ring state machine from the interaction spec.522-380ms draw, 3.5% overshoot, 1200ms hold, 600ms fade. Never fires on523-hover or passive events.524-525-> import { onMount, onCleanup } from \"solid-js\"; interface RingProps {526-> target: () =\> HTMLElement \| null; } export function Ring(props:527-> RingProps) { let svgEl: SVGSVGElement \| undefined; let rectEl:528-> SVGRectElement \| undefined; let fadeTimer: ReturnType\<typeof529-> setTimeout\>; function calibrate() { const el = props.target(); if530-> (!el \|\| !svgEl \|\| !rectEl) return; const { width, height } =531-> el.getBoundingClientRect(); const r = 12; // match popup border-radius532-> const perim = 2 \* (width + height); svgEl.setAttribute(\"viewBox\",533-> \`0 0 \${width} \${height}\`); svgEl.setAttribute(\"width\",534-> String(width)); svgEl.setAttribute(\"height\", String(height));535-> rectEl.setAttribute(\"width\", String(width));536-> rectEl.setAttribute(\"height\", String(height));537-> rectEl.setAttribute(\"rx\", String(r)); rectEl.setAttribute(\"ry\",538-> String(r)); rectEl.style.setProperty(\"\--perim\", String(perim));539-> rectEl.style.strokeDasharray = String(perim);540-> rectEl.style.strokeDashoffset = String(perim); } // Exported: call541-> this on meaningful user actions function trigger() { if (!rectEl)542-> return; rectEl.style.animation = \"none\"; void rectEl.offsetWidth; //543-> force reflow rectEl.style.animation = \"ringDraw 380ms ease-out544-> both\"; clearTimeout(fadeTimer); fadeTimer = setTimeout(beginFade,545-> 380 + 1200); } function beginFade() { if (!rectEl) return;546-> rectEl.style.transition = \"opacity 600ms var(\--retreat)\";547-> rectEl.style.opacity = \"0\"; } onMount(() =\> { calibrate(); //548-> Re-calibrate if popup height changes const ro = new549-> ResizeObserver(calibrate); const el = props.target(); if (el)550-> ro.observe(el); onCleanup(() =\> ro.disconnect()); }); // Make trigger551-> available to parent via ref pattern (window as552-> any).\_\_peachRingTrigger = trigger; return ( \<svg ref={svgEl}553-> style={{ position: \"absolute\", inset: 0, \"pointer-events\":554-> \"none\", \"z-index\": 10 }} \> \<style\>{\` \@keyframes ringDraw { 0%555-> { stroke-dashoffset: var(\--perim); opacity: 0; } 60% {556-> stroke-dashoffset: calc(var(\--perim) \* -0.035); opacity: 0.85; } 78%557-> { stroke-dashoffset: calc(var(\--perim) \* 0.008); } 100% {558-> stroke-dashoffset: 0; opacity: 0.85; } } \`}\</style\> \<rect559-> ref={rectEl} x=\"0\" y=\"0\" fill=\"none\" stroke=\"var(\--ac)\"560-> stroke-width=\"1\" style={{ filter: \"drop-shadow(0 0 5px561-> rgba(217,104,48,0.8))\", transform: \`rotate(-90deg)\`,562-> \"transform-origin\": \"50% 50%\", opacity: \"0\", }} /\> \</svg\> );563-> } // Call this from any interaction handler export function564-> triggerRing() { (window as any).\_\_peachRingTrigger?.(); }565-566-**§ 6 --- Autofill: Content Script**567-568-The content script runs in every page. It must be fast (detection \<569-50ms), secure (never expose vault), and resilient (MutationObserver for570-SPAs). It communicates ONLY via chrome.runtime.sendMessage.571-572-> **🔴 CRITICAL: Content scripts NEVER have access to vault entries. The573-> service worker returns only the id, title, username, and domain of574-> matching entries. Passwords are filled directly by the service worker575-> responding to a FILL message --- the content script never sees the576-> password string.**577-578-**src/content/detect.ts --- Form Detection**579-580-> export interface DetectedField { type: \"username\" \| \"password\" \|581-> \"email\"; el: HTMLInputElement; } const USERNAME_PATTERNS =582-> /user\|email\|login\|mail\|account\|name/i; const PASSWORD_PATTERNS =583-> /pass\|passwd\|pwd/i; export function detectLoginFields(root: Document584-> \| ShadowRoot = document): DetectedField\[\] { const inputs =585-> Array.from(root.querySelectorAll\<HTMLInputElement\>(\"input\"));586-> const fields: DetectedField\[\] = \[\]; for (const el of inputs) { if587-> (!isVisible(el)) continue; const ac = el.autocomplete; const type =588-> el.type.toLowerCase(); const name = (el.name + el.id +589-> el.placeholder).toLowerCase(); // Password: autocomplete \> type \>590-> name if (ac === \"current-password\" \|\| ac === \"new-password\" \|\|591-> type === \"password\") { fields.push({ type: \"password\", el });592-> continue; } if (ac === \"username\" \|\| ac === \"email\" \|\| type593-> === \"email\") { fields.push({ type: \"username\", el }); continue; }594-> if (USERNAME_PATTERNS.test(name)) { fields.push({ type: \"username\",595-> el }); } } return fields; } export function isVisible(el:596-> HTMLElement): boolean { const s = getComputedStyle(el); const r =597-> el.getBoundingClientRect(); return ( s.display !== \"none\" &&598-> s.visibility !== \"hidden\" && parseFloat(s.opacity) \> 0 && r.width599-> \> 0 && r.height \> 0 ); } export function getPageDomain(): string {600-> const parts = location.hostname.split(\".\"); return601-> parts.slice(-2).join(\".\"); }602-603-**src/content/inject.ts --- Shadow DOM Selector**604-605-The autofill selector lives in a Shadow DOM. Page CSS cannot touch it.606-Page JS cannot read it. The dropdown is removed from the DOM on dismiss607---- not hidden.608-609-> import type { VaultEntry } from \"../shared/types\"; export interface610-> SelectorEntry { id: string; title: string; username: string; } export611-> function showAutofillSelector( anchorEl: HTMLInputElement, entries:612-> SelectorEntry\[\], onSelect: (id: string) =\> void ): () =\> void { //613-> Create isolated container const host =614-> document.createElement(\"div\"); host.style.cssText = \` position:615-> absolute; z-index: 2147483647; \`; const shadow = host.attachShadow({616-> mode: \"closed\" }); // closed = invisible to page JS const rect =617-> anchorEl.getBoundingClientRect(); const scrollX = window.scrollX,618-> scrollY = window.scrollY; host.style.top = \`\${rect.bottom +619-> scrollY + 4}px\`; host.style.left = \`\${rect.left + scrollX}px\`;620-> host.style.width = \`\${Math.max(rect.width, 240)}px\`;621-> shadow.innerHTML = \` \<style\> :host { all: initial; } .selector {622-> background: #111009; border: 1px solid rgba(240,232,216,0.09);623-> border-radius: 12px; overflow: hidden; box-shadow: 0 8px 40px624-> rgba(0,0,0,0.8); font-family: Inter, sans-serif; } .entry { display:625-> flex; flex-direction: column; padding: 10px 14px; cursor: pointer;626-> border-bottom: 1px solid rgba(240,232,216,0.05); transition:627-> background 120ms; } .entry:last-child { border-bottom: none; }628-> .entry:hover { background: rgba(240,232,216,0.04); } .entry-title {629-> font-size: 13px; color: #F0E8D8; } .entry-user { font-size: 10px;630-> color: rgba(240,232,216,0.4); font-family: \"Courier New\", monospace;631-> } \</style\> \<div class=\"selector\"\> \${entries.map(e =\> \` \<div632-> class=\"entry\" data-id=\"\${e.id}\"\> \<span633-> class=\"entry-title\"\>\${escapeHtml(e.title)}\</span\> \<span634-> class=\"entry-user\"\>\${escapeHtml(e.username)}\</span\> \</div\>635-> \`).join(\"\")} \</div\> \`;636-> shadow.querySelectorAll(\".entry\").forEach(el =\> {637-> el.addEventListener(\"click\", () =\> { const id = (el as638-> HTMLElement).dataset.id!; onSelect(id); dismiss(); }); });639-> document.body.appendChild(host); function dismiss() { host.remove();640-> // remove, not hide document.removeEventListener(\"click\",641-> outsideClick); } function outsideClick(e: MouseEvent) { if642-> (!host.contains(e.target as Node)) dismiss(); } setTimeout(() =\>643-> document.addEventListener(\"click\", outsideClick), 0); return644-> dismiss; } function escapeHtml(s: string): string { return645-> s.replace(/&/g,\"\&amp;\").replace(/\</g,\"\&lt;\").replace(/\>/g,\"\&gt;\").replace(/\"/g,\"\&quot;\");646-> }647-648-**src/content/fill.ts --- Synchronous Field Fill**649-650-Fill is synchronous. No awaits during the fill path. The entry data is651-already in memory from the AUTOFILL_QUERY response. This is how you652-achieve \<16ms fill time.653-654-> import { isVisible } from \"./detect\"; /\*\* Fill username + password655-> into detected fields. \* Both fields are set in a single tick --- no656-> race conditions. \* Native input event dispatched so React/Vue/Angular657-> state updates. \*/ export function fillFields( usernameEl:658-> HTMLInputElement \| null, passwordEl: HTMLInputElement \| null,659-> username: string, password: string ): void { // Security: verify660-> fields are still visible before filling if (usernameEl &&661-> isVisible(usernameEl)) { setNativeValue(usernameEl, username); } if662-> (passwordEl && isVisible(passwordEl)) { setNativeValue(passwordEl,663-> password); } } /\*\* Dispatch synthetic input + change events so664-> framework state updates. \*/ function setNativeValue(el:665-> HTMLInputElement, value: string): void { const nativeInputValueSetter666-> = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,667-> \"value\")?.set; nativeInputValueSetter?.call(el, value);668-> el.dispatchEvent(new Event(\"input\", { bubbles: true }));669-> el.dispatchEvent(new Event(\"change\", { bubbles: true })); }670->671-> **⚠ nativeInputValueSetter is required for React-controlled inputs.672-> Setting el.value directly does not trigger React\'s synthetic event673-> system. This is the correct approach --- the same technique used by674-> Bitwarden and 1Password.**675-676-**src/content/index.ts --- Content Script Orchestration**677-678-> import { detectLoginFields, getPageDomain } from \"./detect\"; import679-> { showAutofillSelector } from \"./inject\"; import { fillFields } from680-> \"./fill\"; import { setupSaveDetection } from \"./save\"; let681-> currentFields: ReturnType\<typeof detectLoginFields\> = \[\]; async682-> function checkVaultAndInject() { const { data: unlocked } = await683-> chrome.runtime.sendMessage({ type: \"IS_UNLOCKED\" }); if (!unlocked)684-> return; const domain = getPageDomain(); const { data: matches } =685-> await chrome.runtime.sendMessage({ type: \"AUTOFILL_QUERY\", payload:686-> { domain } }); if (!matches \|\| matches.length === 0) return;687-> currentFields = detectLoginFields(); const pwField =688-> currentFields.find(f =\> f.type === \"password\"); const userField =689-> currentFields.find(f =\> f.type === \"username\"); if (!pwField)690-> return; // Inject Peach icon into the password field691-> injectPeachIcon(pwField.el, matches, userField?.el ?? null); }692-> function injectPeachIcon( passwordEl: HTMLInputElement, matches:693-> any\[\], usernameEl: HTMLInputElement \| null ) { const icon =694-> document.createElement(\"button\"); icon.textContent = \"🍑\";695-> icon.style.cssText = \` position: absolute; right: 8px; top: 50%;696-> transform: translateY(-50%); background: none; border: none; cursor:697-> pointer; font-size: 16px; z-index: 2147483646; padding: 4px; \`; const698-> wrapper = passwordEl.parentElement; if (!wrapper) return; const699-> prevPos = getComputedStyle(wrapper).position; if (prevPos ===700-> \"static\") wrapper.style.position = \"relative\";701-> wrapper.appendChild(icon); icon.addEventListener(\"click\", (e) =\> {702-> e.preventDefault(); e.stopPropagation();703-> showAutofillSelector(passwordEl, matches, async (selectedId) =\> { //704-> Request the actual password from the background (content never sees705-> it) const { data: entry } = await chrome.runtime.sendMessage({ type:706-> \"GET_ENTRY_FOR_FILL\", payload: { id: selectedId } }); if (entry)707-> fillFields(usernameEl, passwordEl, entry.username, entry.password);708-> }); }); } // Run on load if (document.readyState === \"loading\") {709-> document.addEventListener(\"DOMContentLoaded\", checkVaultAndInject);710-> } else { checkVaultAndInject(); } // Watch for SPAs that load forms711-> after initial render new MutationObserver(() =\>712-> checkVaultAndInject()).observe( document.body, { childList: true,713-> subtree: true } ); setupSaveDetection();714->715-> **⚠ You need to add a GET_ENTRY_FOR_FILL message type to messages.ts716-> that returns the full entry including password. This message must717-> validate that the caller is the extension itself, not a web page.**718-719-**§ 7 --- Import & Export**720-721-**CSV Import**722-723-Generic CSV. Column mapping is explicit --- never guess. Preview before724-apply.725-726-> import Papa from \"papaparse\"; import type { VaultEntry } from727-> \"../shared/types\"; export interface CSVMapping { title?: string;728-> username?: string; password?: string; url?: string; notes?: string; }729-> export function parseCSV(raw: string): { headers: string\[\]; rows:730-> Record\<string, string\>\[\] } { const result =731-> Papa.parse\<Record\<string, string\>\>(raw, { header: true,732-> skipEmptyLines: true, transformHeader: h =\> h.trim() }); return {733-> headers: result.meta.fields ?? \[\], rows: result.data }; } export734-> function mapCSVToEntries( rows: Record\<string, string\>\[\], mapping:735-> CSVMapping ): Array\<Omit\<VaultEntry, \"id\" \| \"created\" \|736-> \"updated\"\>\> { return rows .map(row =\> ({ title:737-> row\[mapping.title ?? \"\"\] ?? \"\", username: row\[mapping.username738-> ?? \"\"\] ?? \"\", password: row\[mapping.password ?? \"\"\] ?? \"\",739-> url: row\[mapping.url ?? \"\"\] ?? \"\", notes: row\[mapping.notes ??740-> \"\"\] ?? \"\", })) .filter(e =\> e.password); // skip rows with no741-> password }742-743-**1Password JSON Import**744-745-> // 1Password 1PUX export uses a nested structure. // This handles the746-> most common format. export function parse1PasswordJSON(raw: string):747-> Array\<Omit\<VaultEntry, \"id\" \| \"created\" \| \"updated\"\>\> {748-> const data = JSON.parse(raw); const entries: Array\<Omit\<VaultEntry,749-> \"id\" \| \"created\" \| \"updated\"\>\> = \[\]; // 1Password exports:750-> { accounts: \[{ vaults: \[{ items: \[\...\] }\] }\] } const vaults =751-> data?.accounts?.\[0\]?.vaults ?? \[\]; for (const vault of vaults) {752-> for (const item of vault.items ?? \[\]) { if (item.trashed) continue;753-> const fields = item.details?.loginFields ?? \[\]; const username =754-> fields.find((f: any) =\> f.designation === \"username\")?.value ??755-> \"\"; const password = fields.find((f: any) =\> f.designation ===756-> \"password\")?.value ?? \"\"; if (!password) continue; entries.push({757-> title: item.overview?.title ?? \"Untitled\", username, password, url:758-> item.overview?.url ?? \"\", notes: item.details?.notesPlain ?? \"\",759-> }); } } return entries; }760-761-**Encrypted Export**762-763-> // Export vault as encrypted JSON blob. // Uses a separate password764-> (can be the same master password) and a fresh salt. export async765-> function exportEncrypted( entries: VaultEntry\[\], exportPassword:766-> string ): Promise\<string\> { const { deriveKey, generateSalt } =767-> await import(\"../crypto/argon2\"); const { encrypt } = await768-> import(\"../crypto/aes\"); // Fresh salt --- independent of vault769-> at-rest salt const salt = generateSalt(); const key = await770-> deriveKey(exportPassword, salt); const { nonce, ciphertext, tag } =771-> await encrypt(key, JSON.stringify(entries)); const blob =772-> JSON.stringify({ version: 1, type: \"peach-export\", salt:773-> Array.from(salt).map(b =\>774-> b.toString(16).padStart(2,\"0\")).join(\"\"), nonce, ciphertext, tag775-> }); return blob; } export async function importEncrypted( blob:776-> string, exportPassword: string ): Promise\<VaultEntry\[\]\> { const {777-> deriveKey } = await import(\"../crypto/argon2\"); const { decrypt } =778-> await import(\"../crypto/aes\"); const data = JSON.parse(blob); if779-> (data.type !== \"peach-export\" \|\| data.version !== 1) throw new780-> Error(\"Not a Peach export file\"); const salt = new781-> Uint8Array(data.salt.match(/../g).map((h: string) =\> parseInt(h,782-> 16))); const key = await deriveKey(exportPassword, salt); const plain783-> = await decrypt(key, data.nonce, data.ciphertext, data.tag); return784-> JSON.parse(plain); }785-786-**§ 8 --- Release Gate: Manual Test Matrix**787-788-Run every item in this list on Chrome with the unpacked extension before789-calling it daily-driverable. Do not skip rows.790-791-**Vault Core**792-793-- [ ] Create vault with a real master password794-795-- [ ] Lock and reload extension, re-unlock with same password796-797-- [ ] Wrong password → clean failure, no crash, no partial data798-799-- [ ] Add entry, lock, unlock → entry is still there800-801-- [ ] Edit entry → changes persist after lock/unlock cycle802-803-- [ ] Delete entry → gone after lock/unlock804-805-- [ ] Auto-lock fires after 15 minutes (speed-test: set to 1 minute in806-  settings, wait)807-808-- [ ] Clipboard contains password text, 30 seconds later it is cleared809-810-**Autofill --- Real Sites**811-812-Test at minimum these five categories on real sites:813-814-- [ ] Plain HTML login form (e.g. a small site, not a SPA)815-816-- [ ] React-controlled login (e.g. GitHub, Notion)817-818-- [ ] Email-first flow (e.g. Gmail --- first screen is email, second is819-  password)820-821-- [ ] TOTP prompt visible after fill (e.g. GitHub with 2FA enabled)822-823-- [ ] Site with multiple stored credentials --- correct one is824-  pre-highlighted825-826-For each real-site test, verify:827-828-- [ ] Peach icon appears in the password field829-830-- [ ] Selector shows the correct matching entry831-832-- [ ] Clicking the entry fills both username and password833-834-- [ ] Fill does NOT happen silently --- user must click835-836-- [ ] No fill into hidden fields837-838-- [ ] Wrong-domain entries do NOT appear (e.g. a github.com entry should839-  not appear on github.io)840-841-**Save / Update**842-843-- [ ] Log into a new site → save banner appears844-845-- [ ] Banner is subtle and dismissible846-847-- [ ] Accept save → entry appears in vault848-849-- [ ] Change password on a site → update banner offered850-851-- [ ] Update accepted → entry updated in vault852-853-**Import**854-855-- [ ] CSV import with column mapping preview works856-857-- [ ] Entries appear in vault after import858-859-- [ ] Duplicate handling: importing same entry twice does not create860-  corrupt state861-862-**Export & Restore**863-864-- [ ] Encrypted export produces a file865-866-- [ ] Restore from that file with the same password → entries are intact867-868-**Release Blockers (Never Ship with These)**869-870-> **🔴 CRITICAL: Wrong credentials filled on the wrong domain**871->872-> **🔴 CRITICAL: Fill into hidden fields**873->874-> **🔴 CRITICAL: Fill without user interaction (silent autofill)**875->876-> **🔴 CRITICAL: Vault fails to lock cleanly --- key lingers in memory**877->878-> **🔴 CRITICAL: Import corrupts or overwrites entries incorrectly**879->880-> **🔴 CRITICAL: Backup restore fails or returns wrong data**881->882-> **🔴 CRITICAL: console.log anywhere in crypto paths**883-884-**§ 9 --- How to Prompt Your Agent Effectively**885-886-You are working with AI agents that lose context, overshoot scope, and887-invent solutions not in spec. These rules reduce that.888-889-**Prompt Structure for Each Phase**890-891-Give each agent exactly one phase at a time. Never bundle phases. The892-moment you say \"and also do X and Y\", drift starts.893-894-Good prompt pattern:895-896-> You are building the crypto layer of a Chrome extension called Peach.897-> Your task for this session: implement src/crypto/argon2.ts and898-> src/crypto/aes.ts. Requirements: - argon2.ts must export:899-> deriveKey(password: string, salt: Uint8Array): Promise\<Uint8Array\>900-> Parameters: m=65536, t=3, p=4, hashLength=32 Library: hash-wasm901-> (already installed) - aes.ts must export: encrypt(\...),902-> decrypt(\...), zeroKey(\...) Algorithm: AES-256-GCM via SubtleCrypto903-> Fresh CSPRNG nonce every encrypt call AAD: Uint8Array(\[1\]) version904-> byte Type definitions are in src/shared/types.ts (do not modify it).905-> When done, output the file contents only. Do not modify any other906-> files. Do not add error tracking, analytics, or logging of any kind.907-908-**When the Agent Drifts**909-910-- Stop immediately when the output is modifying files outside the stated911-  scope.912-913-- Paste the failing crypto test output back as context and ask for a fix914-  --- do not describe the bug in prose.915-916-- If the agent adds a permission to manifest.json that is not in § 1,917-  reject the whole output and restart that phase.918-919-- Any console.log in crypto code is an automatic restart.920-921-**Phase Commit Protocol**922-923-After each phase:924-925-- Run npm run typecheck --- zero errors before proceeding.926-927-- Run npm run build --- build must succeed.928-929-- Run the crypto tests if in phase 3.930-931-- Load the unpacked extension in Chrome and run the relevant test matrix932-  rows.933-934-- Commit the phase before starting the next one.935-936-> **⚠ A \"clean baseline\" means typecheck passes, build succeeds, and937-> the extension loads in Chrome without errors. If you do not have this,938-> you do not have a baseline --- you have unknown state.**939-940-**§ 10 --- Tonight\'s Execution Order**941-942-Follow this sequence exactly. Every phase has a stop condition. If the943-stop condition is not met, do not proceed.944-945-  ---------------- -------------------------------------------------946-  **Phase 0**      SCAFFOLD: npm create, install deps, vite.config,947-                   manifest.json, file structure. STOP: npm run dev948-                   starts without errors.949-950-  **Phase 1**      CRYPTO: Write argon2.ts, aes.ts, totp.ts. Write951-                   and run all four crypto tests. STOP: all tests952-                   pass.953-954-  **Phase 2**      SERVICE WORKER: Write vault.ts, messages.ts,955-                   clipboard.ts, alarms, index.ts. STOP: typecheck956-                   passes, build succeeds.957-958-  **Phase 3**      POPUP SHELL: Write popup.html with pre-rendered959-                   lock HTML. Write vars.css, popup.css. Mount960-                   SolidJS. STOP: popup opens, shows unlock screen,961-                   no blank flash.962-963-  **Phase 4**      VAULT UI: Lock/unlock flow, vault list,964-                   add/edit/delete, search, TOTP display, generator,965-                   copy with pip pulse and ring. STOP: all vault966-                   operations work end-to-end.967-968-  **Phase 5**      AUTOFILL: detect.ts, inject.ts, fill.ts, content969-                   index. STOP: Peach icon appears on a login form,970-                   selector shows correct entries, fill works.971-972-  **Phase 6**      SAVE/UPDATE: save.ts, submit detection,973-                   save/update banner. STOP: logging into a new site974-                   triggers the save banner.975-976-  **Phase 7**      IMPORT: CSV + 1Password JSON adapter, preview UI,977-                   duplicate handling. STOP: your real passwords are978-                   in Peach.979-980-  **Phase 8**      EXPORT: encrypted export + restore verification.981-                   STOP: you can restore your vault from the backup982-                   file.983-984-  **Phase 9**      TEST MATRIX: every row in § 8. Fix only release985-                   blockers. STOP: no release blockers remain.986-987-  **Phase 10**     MIGRATE: add your real passwords, use Peach988-                   tonight.989-  ---------------- -------------------------------------------------990-991-> *✦ RULE: Do not start Phase 5 (autofill) until Phase 4 (vault UI) is992-> solid. A working vault UI with broken autofill is a daily-driverable993-> extension. A working autofill with a broken vault is not.*994-995-**§ 11 --- Common Failure Modes & Fixes**996-997-**Service Worker Losing State**998-999-MV3 service workers terminate when idle. derivedKey will be null when1000-the service worker wakes back up even if the vault was unlocked.1001-1002-> // In messages.ts, every handler that touches vault state should: case1003-> \"GET_ENTRIES\": if (!vault.isUnlocked()) { return { ok: false, error:1004-> \"locked\" }; // popup shows lock screen } return { ok: true, data:1005-> vault.getEntries() }; // In the popup App.tsx, on mount: onMount(async1006-> () =\> { const { data } = await sendMessage({ type: \"IS_UNLOCKED\"1007-> }); if (!data) showLockScreen(); else loadEntries(); }); // This is1008-> the correct pattern. The popup always checks state on open. // Never1009-> assume the service worker is in the same state it was in last time.1010-1011-**React-Controlled Inputs Not Filling**1012-1013-If fill.ts is setting el.value directly and the site\'s password manager1014-doesn\'t respond, you need the nativeInputValueSetter approach in1015-fill.ts (already shown in § 6). The key insight: React\'s synthetic1016-event system tracks the \"internal\" value, not el.value. You must call1017-the native setter to bypass React\'s wrapper.1018-1019-**Content Script / Popup Communication Failing**1020-1021-> // Always use this wrapper instead of raw chrome.runtime.sendMessage1022-> async function sendMessage(msg: Message): Promise\<MessageResponse\> {1023-> return new Promise((resolve) =\> { chrome.runtime.sendMessage(msg,1024-> (response) =\> { if (chrome.runtime.lastError) { resolve({ ok: false,1025-> error: chrome.runtime.lastError.message }); } else {1026-> resolve(response); } }); }); } // chrome.runtime.lastError MUST be1027-> read or Chrome throws a console error. // This wrapper handles it. Use1028-> it everywhere.1029-1030-**SolidJS Not Updating After Message**1031-1032-> // SolidJS is fine-grained reactive. You need createSignal. // Never1033-> mutate arrays directly --- create new array references. const1034-> \[entries, setEntries\] = createSignal\<VaultEntry\[\]\>(\[\]); //1035-> WRONG: entries().push(newEntry); // CORRECT: setEntries(prev =\>1036-> \[\...prev, newEntry\]);1037-1038-**Argon2id Timing Out in Service Worker**1039-1040-Chrome may kill the service worker during Argon2id derivation (up to1041-1.2s). Prevent this:1042-1043-> // Keep the service worker alive during KDF by sending periodic alarms1044-> // OR use the simpler approach: call chrome.storage.local.get() which1045-> keeps SW alive export async function unlock(password: string):1046-> Promise\<boolean\> { // Ping storage to keep SW alive during1047-> derivation const keepAliveInterval = setInterval(() =\> {1048-> chrome.storage.local.get(\"ping\"); }, 100); try { const stored =1049-> await loadStoredVault(); if (!stored) return false; const salt =1050-> unhex(stored.salt); const key = await deriveKey(password, salt); // up1051-> to 1.2s // \... rest of unlock return true; } finally {1052-> clearInterval(keepAliveInterval); } }1053-1054-**Final Checklist --- Call It Done When All of This Is True**1055-1056-- [ ] npm run typecheck passes with zero errors1057-1058-- [ ] npm run build succeeds1059-1060-- [ ] Extension loads in Chrome without console errors1061-1062-- [ ] All four crypto tests pass1063-1064-- [ ] Vault create / unlock / lock cycle verified manually1065-1066-- [ ] At least your real essential entries are imported1067-1068-- [ ] Encrypted backup exported and restore verified1069-1070-- [ ] Autofill works on the sites you personally use most1071-1072-- [ ] Save/update flow triggered on at least one real site1073-1074-- [ ] Clipboard clear verified (set timer to 10s for testing)1075-1076-- [ ] Auto-lock verified (set to 1 minute for testing)1077-1078-- [ ] No wrong-domain entries appear in autofill1079-1080-- [ ] No silent fill occurs on any tested site1081-1082-- [ ] No console.log in any crypto file1083-1084-*That is a daily-driver. That is tonight.*

view file at the parent

peach_demo_update_spec.md+0 −1102
@@ -1,1102 +0,0 @@1-# Peach Website Demo Update Spec2-**Target: peachpasswords.com — HeroDemo.astro**3-**Extension version: 1.4.12**4-**Goal: make the website demo show the real extension as faithfully as possible**5-6----7-8-## 1. Overview9-10-The live demo at `peachpasswords.com` renders a simulated 320×520px popup shell directly in the browser. It currently shows a subset of the real popup's surfaces and uses some placeholder or outdated data/markup. This document is a complete spec for bringing the demo to parity with the real extension's v1.4.12 state.11-12-The implementation approach is:13-1. Copy the compiled CSS from `popup.css` + `vars.css` into the demo as-is (single source of truth).14-2. Rebuild the demo HTML surfaces to match the real component markup exactly.15-3. Wire up the demo's click/hover interactivity to show as many surfaces as possible.16-4. Use realistic sample data that tells a story about Peach's capabilities.17-18----19-20-## 2. CSS strategy (highest priority)21-22-The demo currently has its own CSS. **Replace it** with a direct copy of the extension's compiled CSS:23-24-```25-src/popup/styles/vars.css   → embedded in <style> in HeroDemo.astro26-src/popup/styles/popup.css  → embedded in <style> in HeroDemo.astro27-```28-29-Do **not** maintain a separate CSS copy. Instead add a build step (or Astro integration) that reads both files from the extension workspace and injects them at build time. This guarantees the demo never drifts from the real product.30-31-The shell wrapper the website uses should be:32-33-```html34-<div class="popup-root peach-theme" id="hd-root">35-  <!-- surfaces go here -->36-</div>37-```38-39-Override only one thing at the demo level: scale the popup root down so it fits the marketing page layout:40-41-```css42-#hd-root {43-  transform: scale(0.88);44-  transform-origin: top center;45-  border-radius: 20px;46-  overflow: hidden;47-  box-shadow: 0 32px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);48-}49-```50-51----52-53-## 3. Fonts54-55-The real extension self-hosts fonts from `public/fonts/`. The website should reference the same files or load them from a CDN. Required faces:56-57-| Family | Weights used | Role |58-|--------|-------------|------|59-| Cormorant Garamond | 300 italic, 400 italic | Display headings (lock screen, entry name) |60-| IBM Plex Mono | 400 | Metadata, labels, mono values, TOTP code |61-| Inter Variable | 300, 400, 500 | UI body text |62-63-The `@font-face` declarations in `vars.css` already handle this. Copy them verbatim.64-65----66-67-## 4. Demo surface inventory68-69-Show the following surfaces. Each has a tab/button in the demo chrome that reveals it. The demo should start on **Surface A (unlocked entry detail)** — this is the most impressive screen and what a visitor should see first.70-71-| ID | Surface | Entry point |72-|----|---------|-------------|73-| A | Unlocked — Entry detail (GitHub) | Default / "detail" button |74-| B | Unlocked — Entry detail (Stripe, with TOTP) | Click "Stripe" in search bar |75-| C | Vault list (with sort + tab-match badge) | Click search bar input |76-| D | Add new entry | "+" button in header |77-| E | Settings | Gear icon in header |78-| F | Vault Health | From Settings → "open" |79-| G | Lock screen (unlock) | Lock icon in header |80-| H | Lock screen (onboarding / new vault) | Toggle inside demo |81-82-The passkey approval, CJK export/import, sync pairing, and export surfaces can be shown as static screenshots below the interactive demo rather than as interactive surfaces (they are powerful features but too branched for a simple demo).83-84----85-86-## 5. Sample data87-88-Use this fixed vault for the demo. It demonstrates all entry types and features:89-90-```js91-const DEMO_ENTRIES = [92-  {93-    id: "demo-1",94-    type: "login",95-    title: "GitHub",96-    url: "https://github.com",97-    username: "juni_patel",98-    password: "p#9Kx!mQ3rL@vZ",99-    totp: null,100-    tags: ["work", "dev"],101-    breach: 0,102-    createdAt: Date.now() - 86400000 * 90,103-    modifiedAt: Date.now() - 86400000 * 14,104-  },105-  {106-    id: "demo-2",107-    type: "login",108-    title: "Stripe",109-    url: "https://dashboard.stripe.com",110-    username: "[email protected]",111-    password: "Tr!p0d$2024#Acme",112-    totp: "JBSWY3DPEHPK3PXP",   // shows TOTP band113-    tags: ["work"],114-    breach: 0,115-    createdAt: Date.now() - 86400000 * 120,116-    modifiedAt: Date.now() - 86400000 * 3,117-  },118-  {119-    id: "demo-3",120-    type: "login",121-    title: "Netflix",122-    url: "https://netflix.com",123-    username: "[email protected]",124-    password: "password123",  // deliberately weak — shows breach badge125-    tags: ["personal"],126-    breach: 54231,  // shows breach count127-    createdAt: Date.now() - 86400000 * 400,128-    modifiedAt: Date.now() - 86400000 * 400,129-  },130-  {131-    id: "demo-4",132-    type: "passkey",133-    title: "Apple ID",134-    rpId: "apple.com",135-    userName: "juni_patel",136-    userDisplayName: "Juni Patel",137-    tags: ["personal"],138-    createdAt: Date.now() - 86400000 * 30,139-    modifiedAt: Date.now() - 86400000 * 30,140-  },141-  {142-    id: "demo-5",143-    type: "note",144-    title: "Server SSH keys",145-    notes: "prod: ssh -i ~/.ssh/peach_prod [email protected]\nstaging: ssh -i ~/.ssh/peach_stage [email protected]",146-    tags: ["dev"],147-    createdAt: Date.now() - 86400000 * 60,148-    modifiedAt: Date.now() - 86400000 * 7,149-  },150-  {151-    id: "demo-6",152-    type: "login",153-    title: "Gmail",154-    url: "https://mail.google.com",155-    username: "[email protected]",156-    password: "Gm4!l$ecure#88",157-    tags: ["personal"],158-    breach: 0,159-    canary: { label: "honeypot", webhookUrl: "https://hooks.example.com/canary" }, // shows canary badge160-    createdAt: Date.now() - 86400000 * 200,161-    modifiedAt: Date.now() - 86400000 * 45,162-  }163-];164-```165-166-The tab domain for the demo should be simulated as `github.com` — this causes "GitHub" to show the tab-match orange dot in the vault list.167-168----169-170-## 6. Surface A — Entry detail (GitHub, login)171-172-This is the default surface. HTML structure matches `Entry.tsx` exactly:173-174-```html175-<section id="hd-shell" class="peach-shell peach-theme" data-surface="extension">176-  <!-- Top chrome -->177-  <header class="peach-chrome">178-    <div class="peach-wordmark-wrap">179-      <!-- PeachWordmark SVG with unlocked=true (no pip hidden class) -->180-      <svg class="peach-brand-mark" ...></svg>181-      <span class="peach-wordmark-text">peach</span>182-      <span class="peach-status-pip unlocked"></span>183-    </div>184-    <div class="peach-chrome-actions">185-      <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-btn-settings" aria-label="Settings">186-        <!-- gear SVG -->187-      </button>188-      <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-btn-add" aria-label="New entry">189-        <!-- + SVG -->190-      </button>191-      <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-lock-btn" id="hd-btn-lock" aria-label="Lock vault">192-        <!-- padlock SVG -->193-      </button>194-    </div>195-  </header>196-197-  <!-- Content frame (entry detail) -->198-  <div class="peach-shell-body">199-    <div class="peach-shell-veil"></div>200-    <div class="peach-content-frame" data-content-phase="idle">201-202-      <!-- Entry detail -->203-      <div class="peach-ghost"></div>204-      <div class="peach-stage">205-206-        <!-- Entry name -->207-        <div class="peach-entry-name-wrap">208-          <button class="peach-entry-name-button">209-            <h1 class="peach-entry-name" aria-label="GitHub">210-              <span class="peach-entry-name-text">211-                <!-- Each char wrapped: -->212-                <span class="peach-entry-name-char" style="--char-index:0">G</span>213-                <span class="peach-entry-name-char" style="--char-index:1">i</span>214-                <span class="peach-entry-name-char" style="--char-index:2">t</span>215-                <span class="peach-entry-name-char" style="--char-index:3">H</span>216-                <span class="peach-entry-name-char" style="--char-index:4">u</span>217-                <span class="peach-entry-name-char" style="--char-index:5">b</span>218-              </span>219-            </h1>220-          </button>221-        </div>222-223-        <!-- Domain + tags -->224-        <div class="peach-domain">225-          <div class="peach-domain-row">226-            <!-- favicon img or login icon fallback -->227-            <img src="https://icons.duckduckgo.com/ip3/github.com.ico" width="16" height="16" style="border-radius:3px" alt="">228-            <span class="peach-domain-value">github.com</span>229-          </div>230-          <!-- Tags -->231-          <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">work</span>232-          <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">dev</span>233-        </div>234-235-        <!-- Fields section -->236-        <section class="peach-fields">237-          <!-- Username field -->238-          <div class="peach-field-card">239-            <span class="peach-label">username</span>240-            <div class="peach-field-value-row">241-              <span class="peach-field-value">juni_patel</span>242-              <div class="peach-field-actions">243-                <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn" id="hd-copy-username">244-                  <span class="peach-copy-icon">245-                    <!-- copy SVG -->246-                  </span>247-                  <span class="peach-copy-sheen"></span>248-                </button>249-              </div>250-            </div>251-          </div>252-253-          <!-- Password field -->254-          <div class="peach-field-card">255-            <span class="peach-label">password</span>256-            <div class="peach-field-value-row">257-              <div class="peach-password-value">258-                <span class="peach-password-mask">...............</span>259-              </div>260-              <div class="peach-field-actions">261-                <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-reveal-password" aria-label="Reveal password">262-                  <!-- eye SVG -->263-                </button>264-                <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn" id="hd-copy-password">265-                  <span class="peach-copy-icon"><!-- copy SVG --></span>266-                  <span class="peach-copy-sheen"></span>267-                </button>268-              </div>269-            </div>270-          </div>271-        </section>272-273-      </div>274-    </div>275-276-    <!-- Search bar -->277-    <section class="peach-search-wrap">278-      <div class="peach-search-bar">279-        <span class="peach-search-icon"><!-- search SVG --></span>280-        <input class="peach-search-input" placeholder="search vault..." readonly id="hd-search-input">281-        <div class="peach-search-meta">282-          <span class="peach-search-counter">1 / 6</span>283-          <span class="peach-divider"></span>284-          <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-prev"><!-- chevron left --></button>285-          <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-next"><!-- chevron right --></button>286-        </div>287-      </div>288-    </section>289-  </div>290-</section>291-```292-293-### A — Interactive behaviors294-295-| Action | Effect |296-|--------|--------|297-| Click copy (username) | `peach-copy-btn` gets `.is-copied` for 1.5s; drain animation plays |298-| Click copy (password) | Same copy animation |299-| Click reveal | Toggle `.peach-password-mask` ↔ `.peach-password-text` content |300-| Click prev/next arrows | Cycle through `DEMO_ENTRIES`; re-render entry name chars with `--char-index` delay |301-| Click search bar | Show Surface C (vault list overlay) |302-| Click settings gear | Show Surface E |303-| Click "+" | Show Surface D |304-| Click lock | Show Surface G |305-306----307-308-## 7. Surface B — Entry detail (Stripe, with TOTP)309-310-Same structure as A but with:311-- Entry name: "Stripe"312-- Domain: `dashboard.stripe.com`313-- Username: `[email protected]`  314-- Tags: `work`315-- TOTP band rendered below the password field:316-317-```html318-<section class="peach-totp-band">319-  <div class="peach-totp-main">320-    <span class="peach-label">2-factor</span>321-    <div class="peach-totp-code-row">322-      <p class="peach-totp-code">323-        <span class="peach-totp-code-text">482 917</span>324-      </p>325-      <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn">326-        <span class="peach-copy-icon"><!-- copy SVG --></span>327-        <span class="peach-copy-sheen"></span>328-      </button>329-    </div>330-  </div>331-  <div class="peach-totp-meta">332-    <span class="peach-label">expires</span>333-    <div class="peach-totp-countdown" id="hd-totp-remaining">18s</div>334-    <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">335-      <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />336-      <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"337-        style="stroke-dasharray: 106.8; stroke-dashoffset: 35.6;" />338-    </svg>339-  </div>340-  <div class="peach-totp-progress">341-    <div class="peach-totp-progress-bar" style="width: 60%;"></div>342-  </div>343-</section>344-```345-346-Animate the TOTP ring/countdown in the demo: use a JavaScript interval to decrement a fake countdown from 30→0, updating `stroke-dashoffset` and the countdown text. Reset to 30 and generate a new fake 6-digit code when it hits 0. This makes the demo feel alive.347-348-Surface B is reached by clicking "Stripe" in the vault list (Surface C).349-350----351-352-## 8. Surface C — Vault list353-354-```html355-<div class="peach-overlay-view ov-enter-up" id="hd-view-list" style="display:flex">356-  <!-- Top bar with back button + search -->357-  <div class="peach-list-top">358-    <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-list-close">359-      <!-- chevron left -->360-    </button>361-    <input class="peach-search-input" placeholder="search vault..." style="font-size:14px" id="hd-list-search">362-    <span class="peach-search-counter peach-search-counter-list">6 entries</span>363-  </div>364-365-  <!-- Sort bar (new in 1.4.12) -->366-  <div class="peach-sort-bar">367-    <button class="peach-sort-btn is-active">smart</button>368-    <button class="peach-sort-btn">A–Z</button>369-    <button class="peach-sort-btn">Z–A</button>370-    <button class="peach-sort-btn">newest</button>371-    <span class="peach-sort-domain-hint">github.com</span>372-  </div>373-374-  <!-- Entry list -->375-  <div class="peach-vault-list">376-    <!-- GitHub — tab match (is-tab-match) -->377-    <button class="peach-entry-row is-active is-tab-match" style="animation-delay:0ms">378-      <div class="peach-entry-row-icon">379-        <img src="https://icons.duckduckgo.com/ip3/github.com.ico" width="16" height="16" style="border-radius:3px" alt="">380-      </div>381-      <div class="peach-entry-row-info">382-        <span class="peach-entry-row-name">383-          GitHub384-          <span class="peach-tab-match-badge" title="Matches current tab">●</span>385-        </span>386-        <span class="peach-entry-row-meta">github.com</span>387-      </div>388-    </button>389-390-    <!-- Stripe -->391-    <button class="peach-entry-row" style="animation-delay:20ms" id="hd-list-stripe">392-      <div class="peach-entry-row-icon">393-        <img src="https://icons.duckduckgo.com/ip3/dashboard.stripe.com.ico" width="16" height="16" style="border-radius:3px" alt="">394-      </div>395-      <div class="peach-entry-row-info">396-        <span class="peach-entry-row-name">Stripe</span>397-        <span class="peach-entry-row-meta">dashboard.stripe.com</span>398-      </div>399-    </button>400-401-    <!-- Netflix — breached -->402-    <button class="peach-entry-row" style="animation-delay:40ms">403-      <div class="peach-entry-row-icon is-breach">404-        <!-- login icon SVG -->405-        <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">406-          <rect x="2" y="5" width="7" height="5" rx="1.2"/>407-          <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5"/>408-        </svg>409-      </div>410-      <div class="peach-entry-row-info">411-        <span class="peach-entry-row-name">Netflix</span>412-        <span class="peach-entry-row-meta">netflix.com</span>413-      </div>414-      <span class="peach-entry-row-dot" aria-label="breach"></span>415-    </button>416-417-    <!-- Apple ID — passkey -->418-    <button class="peach-entry-row" style="animation-delay:60ms">419-      <div class="peach-entry-row-icon">420-        <!-- passkey icon SVG -->421-        <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">422-          <circle cx="4" cy="4.5" r="3"/>423-          <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10"/>424-        </svg>425-      </div>426-      <div class="peach-entry-row-info">427-        <span class="peach-entry-row-name">428-          Apple ID429-          <span class="peach-passkey-badge">passkey</span>430-        </span>431-        <span class="peach-entry-row-meta">apple.com</span>432-      </div>433-    </button>434-435-    <!-- Server SSH keys — note -->436-    <button class="peach-entry-row" style="animation-delay:80ms">437-      <div class="peach-entry-row-icon">438-        <!-- note icon SVG -->439-        <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">440-          <rect x="1.5" y="1.5" width="8" height="9" rx="1.5"/>441-          <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2"/>442-        </svg>443-      </div>444-      <div class="peach-entry-row-info">445-        <span class="peach-entry-row-name">Server SSH keys</span>446-        <span class="peach-entry-row-meta">prod: ssh -i ~/.ssh/p...</span>447-      </div>448-    </button>449-450-    <!-- Gmail — canary -->451-    <button class="peach-entry-row" style="animation-delay:100ms">452-      <div class="peach-entry-row-icon">453-        <img src="https://icons.duckduckgo.com/ip3/mail.google.com.ico" width="16" height="16" style="border-radius:3px" alt="">454-      </div>455-      <div class="peach-entry-row-info">456-        <span class="peach-entry-row-name">Gmail</span>457-        <span class="peach-entry-row-meta">mail.google.com</span>458-      </div>459-      <span class="peach-entry-row-canary" title="Canary: honeypot">🪤</span>460-    </button>461-  </div>462-463-  <!-- New entry row -->464-  <button class="peach-new-entry-row" id="hd-list-add">465-    <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">466-      <path d="M5 1v8M1 5h8"/>467-    </svg>468-    new entry469-  </button>470-</div>471-```472-473-### C — Interactive behaviors474-475-| Action | Effect |476-|--------|--------|477-| Type in search input | Filter rows client-side with JS (simple substring match on title/domain) |478-| Click sort button | Toggle `.is-active` between sort buttons; demo doesn't need to re-sort, just show the UI working |479-| Click "GitHub" row | Close list, show Surface A |480-| Click "Stripe" row | Close list, show Surface B (with TOTP) |481-| Click "Netflix" row | Close list, show breach version of entry detail (see §9) |482-| Click "new entry" | Show Surface D |483-| Click back / Esc | Close list, return to current entry detail |484-485----486-487-## 9. Breach entry detail (Netflix)488-489-Same structure as Surface A, but with the breach strip rendered:490-491-```html492-<!-- Breach badge — above the entry name -->493-<div class="peach-breach-badge">494-  <span>⚠ 54,231 data breach exposures</span>495-  <button class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action">change password</button>496-</div>497-```498-499-Also the icon shows with `is-breach` class applied.500-501----502-503-## 10. Surface D — Add new entry504-505-```html506-<div class="peach-overlay-view ov-enter-right" id="hd-view-new" style="display:flex">507-  <div class="peach-overlay-header">508-    <button class="peach-btn peach-btn--subtle peach-btn--sm peach-overlay-action" id="hd-new-cancel">cancel</button>509-    <span class="peach-overlay-title">new entry</span>510-    <button class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action" id="hd-new-create" disabled>create</button>511-  </div>512-513-  <div class="peach-new-body">514-    <!-- Type tabs -->515-    <div class="peach-type-tabs">516-      <button class="peach-type-tab is-active" id="hd-tab-login">login</button>517-      <button class="peach-type-tab" id="hd-tab-note">note</button>518-    </div>519-520-    <!-- Title input -->521-    <input class="peach-new-name-input" placeholder="entry name" id="hd-new-title">522-523-    <!-- URL row -->524-    <div class="peach-domain peach-new-domain">525-      <input class="peach-domain-value peach-new-field-input" placeholder="https://..." id="hd-new-url">526-    </div>527-528-    <!-- Fields section -->529-    <section class="peach-fields peach-new-fields">530-      <div class="peach-field-card">531-        <span class="peach-label">username</span>532-        <div class="peach-field-value-row">533-          <input class="peach-field-value peach-new-field-input" type="text" placeholder="username or email" id="hd-new-username" autocomplete="off">534-        </div>535-      </div>536-537-      <div class="peach-field-card">538-        <span class="peach-label">password</span>539-        <div class="peach-field-value-row">540-          <input class="peach-field-value peach-new-field-input" type="password" placeholder="password" id="hd-new-password" autocomplete="new-password">541-          <!-- Inline generator button -->542-          <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-new-generate" title="Generate password">543-            <!-- dice/sparkle SVG -->544-            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">545-              <rect x="1.5" y="1.5" width="10" height="10" rx="2"/>546-              <circle cx="4.5" cy="4.5" r="1" fill="currentColor" stroke="none"/>547-              <circle cx="8.5" cy="8.5" r="1" fill="currentColor" stroke="none"/>548-              <circle cx="4.5" cy="8.5" r="1" fill="currentColor" stroke="none"/>549-              <circle cx="8.5" cy="4.5" r="1" fill="currentColor" stroke="none"/>550-              <circle cx="6.5" cy="6.5" r="1" fill="currentColor" stroke="none"/>551-            </svg>552-          </button>553-        </div>554-        <!-- Strength hairline (shown when password has content) -->555-        <div class="peach-strength-hairline" id="hd-strength-bar" style="display:none">556-          <div class="peach-strength-hairline-track">557-            <div class="peach-strength-hairline-fill" id="hd-strength-fill" style="width:0%"></div>558-          </div>559-          <span class="peach-strength-label" id="hd-strength-label"></span>560-        </div>561-      </div>562-563-      <div class="peach-field-card">564-        <span class="peach-label">totp secret</span>565-        <div class="peach-field-value-row">566-          <input class="peach-field-value peach-new-field-input" type="text" placeholder="otpauth:// or base32" id="hd-new-totp" autocomplete="off">567-        </div>568-      </div>569-570-      <div class="peach-field-card">571-        <span class="peach-label">notes</span>572-        <div class="peach-field-value-row">573-          <textarea class="peach-new-textarea" rows="3" placeholder="optional notes" id="hd-new-notes"></textarea>574-        </div>575-      </div>576-    </section>577-578-    <!-- Tags -->579-    <div class="peach-field-card" style="margin-top:12px">580-      <span class="peach-label">tags</span>581-      <div class="peach-tag-input-wrap">582-        <input class="peach-tag-input" placeholder="add tags..." id="hd-new-tags">583-      </div>584-      <div class="peach-tag-suggestions" style="margin-top:4px">585-        <button class="peach-tag-suggestion">work</button>586-        <button class="peach-tag-suggestion">personal</button>587-        <button class="peach-tag-suggestion">dev</button>588-      </div>589-    </div>590-  </div>591-</div>592-```593-594-### D — Interactive behaviors595-596-| Action | Effect |597-|--------|--------|598-| Click "note" tab | Toggle tab active state; hide url/username/password/totp fields; show note textarea |599-| Click "login" tab | Toggle back; restore login fields |600-| Type in password field | Show strength bar; update fill width and label text (simple heuristic OK in demo) |601-| Click generate button | Fill password field with a random 16-char password; trigger strength bar update |602-| All fields filled | Enable "create" button |603-| Click "create" | Briefly show "saving…" then return to entry detail with a success animation |604-| Click "cancel" | Close overlay, return to entry detail |605-606----607-608-## 11. Surface E — Settings609-610-```html611-<div class="peach-overlay-view ov-enter-right" id="hd-view-settings" style="display:flex">612-  <div class="peach-overlay-header">613-    <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-settings-back"><!-- chevron --></button>614-    <span class="peach-overlay-title">settings</span>615-    <div class="peach-overlay-spacer"></div>616-  </div>617-618-  <div class="peach-settings-body">619-620-    <!-- Security group -->621-    <div class="peach-settings-group">622-      <p class="peach-settings-label">security</p>623-      <div class="peach-settings-row">624-        <span>Lock on close</span>625-        <button class="peach-toggle is-on"><span class="peach-toggle-knob"></span></button>626-      </div>627-      <div class="peach-settings-row">628-        <span>Breach monitoring</span>629-        <button class="peach-toggle is-on"><span class="peach-toggle-knob"></span></button>630-      </div>631-      <div class="peach-settings-row peach-settings-row--meta">632-        <span>Check all passwords</span>633-        <button class="peach-btn peach-btn--subtle peach-btn--sm">run</button>634-      </div>635-      <div class="peach-settings-row peach-settings-row--meta">636-        <span>Add canary entry</span>637-        <button class="peach-btn peach-btn--subtle peach-btn--sm">open</button>638-      </div>639-    </div>640-641-    <!-- Duress password group (new in 1.4.12) -->642-    <div class="peach-settings-group">643-      <p class="peach-settings-label">duress password</p>644-      <div class="peach-settings-row peach-settings-row--top">645-        <div class="peach-settings-row-main">646-          <span>Duress password</span>647-          <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">648-            If entered under coercion, shows a decoy empty vault instead of your real one.649-          </span>650-        </div>651-        <div class="peach-duress-status">off</div>652-      </div>653-      <div class="peach-settings-row peach-settings-row--meta">654-        <button class="peach-btn peach-btn--subtle peach-btn--sm">Set up</button>655-      </div>656-    </div>657-658-    <!-- Passkeys group -->659-    <div class="peach-settings-group">660-      <p class="peach-settings-label">passkeys</p>661-      <div class="peach-settings-row peach-settings-row--top">662-        <div class="peach-settings-row-main">663-          <span>Intercept passkey requests</span>664-          <span class="peach-settings-value" style="font-size:11px;color:var(--t2)">665-            Peach can handle passkey sign-in and creation on supported Chromium browsers.666-          </span>667-        </div>668-        <button class="peach-toggle is-on"><span class="peach-toggle-knob"></span></button>669-      </div>670-      <div class="peach-settings-row peach-settings-row--meta">671-        <span>Status</span>672-        <span class="peach-settings-value">Active</span>673-      </div>674-    </div>675-676-    <!-- Sync group — show Pro gate -->677-    <div class="peach-settings-group" style="position:relative">678-      <p class="peach-settings-label">sync</p>679-      <div class="peach-sync-pro-gate">680-        <div class="peach-sync-pro-gate-blur">681-          <div class="peach-settings-row">682-            <span>Status</span>683-            <span class="peach-settings-value peach-settings-value--status">684-              <span class="peach-sync-status-dot" style="background:var(--peach-sync-off)"></span>685-              Offline686-            </span>687-          </div>688-        </div>689-        <div class="peach-sync-pro-gate-overlay">690-          <span class="peach-sync-pro-badge">Pro</span>691-          <span style="font-size:13px;color:rgba(255,255,255,0.7)">Sync across all your devices</span>692-          <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer"693-             class="peach-link" style="color:#D96830;font-size:13px">Upgrade to Pro →</a>694-        </div>695-      </div>696-    </div>697-698-    <!-- Vault group -->699-    <div class="peach-settings-group">700-      <p class="peach-settings-label">vault</p>701-      <div class="peach-settings-row peach-settings-row--meta">702-        <span>Entries</span>703-        <span class="peach-settings-value">6</span>704-      </div>705-      <div class="peach-settings-row peach-settings-row--meta">706-        <span>Theme</span>707-        <button class="peach-btn peach-btn--subtle peach-btn--sm" id="hd-settings-theme">light</button>708-      </div>709-      <div class="peach-settings-row peach-settings-row--meta">710-        <span>Vault Health</span>711-        <button class="peach-btn peach-btn--subtle peach-btn--sm" id="hd-settings-health">open</button>712-      </div>713-    </div>714-715-    <!-- Footer -->716-    <div class="peach-settings-footer">717-      <span>Peach 1.4.12</span>718-      <span class="peach-divider"></span>719-      <span>Browser extension</span>720-    </div>721-722-  </div>723-</div>724-```725-726-### E — Interactive behaviors727-728-| Action | Effect |729-|--------|--------|730-| Toggle "Lock on close" | Toggle `.is-on` class on button |731-| Toggle "Breach monitoring" | Toggle `.is-on` |732-| Toggle "Intercept passkey requests" | Toggle `.is-on` |733-| Click "Theme" | Switch demo between dark/light by toggling `.light` on `#hd-root` |734-| Click "Vault Health" → open | Show Surface F |735-| Click back | Close settings, return to entry detail |736-737----738-739-## 12. Surface F — Vault Health740-741-```html742-<div class="peach-overlay-view ov-enter-right" id="hd-view-health" style="display:flex">743-  <div class="peach-overlay-header">744-    <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" id="hd-health-back"><!-- chevron --></button>745-    <span class="peach-overlay-title">vault health</span>746-    <div class="peach-overlay-spacer"></div>747-  </div>748-749-  <div class="peach-health-body">750-    <div class="peach-health-summary">751-      <span class="peach-health-summary-warn">2 entries need attention</span>752-    </div>753-    <div class="peach-health-check-row">754-      <button class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn">755-        Check all breaches now756-      </button>757-    </div>758-    <div class="peach-health-sections">759-760-      <!-- Breached section (1 entry) -->761-      <div class="peach-health-section">762-        <button class="peach-health-section-header">763-          <span class="peach-health-section-title">Breached</span>764-          <span class="peach-health-section-count">1</span>765-          <svg width="8" height="5" viewBox="0 0 8 5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">766-            <path d="M1 1l3 3 3-3"/>767-          </svg>768-        </button>769-        <div class="peach-health-section-body">770-          <div class="peach-health-row">771-            <div class="peach-health-row-info">772-              <span class="peach-health-row-name">Netflix</span>773-              <span class="peach-health-row-meta" style="color:var(--peach-breach)">54,231 exposures</span>774-            </div>775-            <button class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn">Fix</button>776-          </div>777-        </div>778-      </div>779-780-      <!-- Reused section (0) -->781-      <div class="peach-health-section">782-        <button class="peach-health-section-header">783-          <span class="peach-health-section-title">Reused</span>784-          <span class="peach-health-section-count">0</span>785-          <svg width="8" height="5" viewBox="0 0 8 5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">786-            <path d="M1 1l3 3 3-3"/>787-          </svg>788-        </button>789-        <div class="peach-health-section-body" style="display:none">790-          <div class="peach-list-empty"><span>No reused passwords</span></div>791-        </div>792-      </div>793-794-      <!-- Weak section (1 entry — Netflix) -->795-      <div class="peach-health-section">796-        <button class="peach-health-section-header">797-          <span class="peach-health-section-title">Weak</span>798-          <span class="peach-health-section-count">1</span>799-          <svg width="8" height="5" viewBox="0 0 8 5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">800-            <path d="M1 1l3 3 3-3"/>801-          </svg>802-        </button>803-        <div class="peach-health-section-body">804-          <div class="peach-health-row">805-            <div class="peach-health-row-info">806-              <span class="peach-health-row-name">netflix.com</span>807-              <span class="peach-health-row-meta">score: 0 (very weak)</span>808-            </div>809-            <button class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn">Fix</button>810-          </div>811-        </div>812-      </div>813-814-      <!-- Old section (0) -->815-      <div class="peach-health-section">816-        <button class="peach-health-section-header">817-          <span class="peach-health-section-title">Old</span>818-          <span class="peach-health-section-count">1</span>819-          <svg width="8" height="5" viewBox="0 0 8 5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">820-            <path d="M1 1l3 3 3-3"/>821-          </svg>822-        </button>823-        <div class="peach-health-section-body">824-          <div class="peach-health-row">825-            <div class="peach-health-row-info">826-              <span class="peach-health-row-name">Netflix</span>827-              <span class="peach-health-row-meta">last changed: over 1 year ago</span>828-            </div>829-            <button class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn">Fix</button>830-          </div>831-        </div>832-      </div>833-834-    </div>835-  </div>836-</div>837-```838-839-### F — Interactive behaviors840-841-| Action | Effect |842-|--------|--------|843-| Click section header | Collapse/expand that section body (toggle `display:none`) |844-| Click "Check all breaches" | Brief "checking…" label then return to "2 entries need attention" |845-| Click back | Return to Settings |846-847----848-849-## 13. Surface G — Lock screen (unlock)850-851-```html852-<section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" id="hd-view-lock">853-  <header class="peach-screen-header lock-screen-header">854-    <!-- PeachWordmark -->855-    <div class="peach-wordmark-wrap">856-      <svg class="peach-brand-mark" ...></svg>857-      <span class="peach-wordmark-text">peach</span>858-    </div>859-  </header>860-861-  <div class="peach-lock-hero peach-lock-hero--unlock">862-    <div class="peach-lock-hero-art" aria-hidden="true">863-      <!-- PeachMark SVG (large, faint background mark) -->864-    </div>865-    <span class="peach-lock-eyebrow">vault locked</span>866-    <h1 class="peach-onboarding-headline peach-lock-headline--unlock">867-      unlock<br>your vault.868-    </h1>869-    <p class="peach-subline peach-subline--lock">870-      Everything stays local and encrypted until you unlock.871-    </p>872-  </div>873-874-  <section class="peach-lock-panel peach-lock-panel--unlock">875-    <label class="peach-label" for="hd-unlock-password">master password</label>876-    <input id="hd-unlock-password"877-      class="peach-underline-input peach-unlock-input lock-password-input"878-      type="password"879-      placeholder="master password"880-      autocomplete="current-password">881-    <button class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit" id="hd-unlock-btn" disabled>882-      unlock883-    </button>884-    <p class="peach-hint">encrypted vault · stored locally</p>885-  </section>886-</section>887-```888-889-### G — Interactive behaviors890-891-| Action | Effect |892-|--------|--------|893-| Type in password field | Enable "unlock" button when field is non-empty |894-| Click "unlock" | Show brief "unlocking…" state then transition back to Surface A with spring animation |895-| Toggle button inside demo | Show Surface H (onboarding/new vault) instead |896-897----898-899-## 14. Surface H — Lock screen (onboarding / new vault)900-901-```html902-<section class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding" id="hd-view-onboarding">903-  <header class="peach-screen-header lock-screen-header">904-    <div class="peach-wordmark-wrap"><!-- PeachWordmark --></div>905-  </header>906-907-  <div class="peach-lock-hero">908-    <div class="peach-lock-hero-art" aria-hidden="true"><!-- PeachMark --></div>909-    <span class="peach-lock-eyebrow">new vault</span>910-    <h1 class="peach-onboarding-headline">911-      your secrets,<br>kept close.912-    </h1>913-    <p class="peach-subline peach-subline--lock">914-      Create a master password to encrypt your vault. Or restore from an existing backup.915-    </p>916-  </div>917-918-  <section class="peach-lock-panel peach-lock-panel--primary">919-    <label class="peach-label" for="hd-create-pw">master password</label>920-    <input id="hd-create-pw" class="peach-underline-input peach-unlock-input lock-password-input"921-      type="password" placeholder="master password" autocomplete="new-password">922-923-    <label class="peach-label" for="hd-create-confirm">confirm password</label>924-    <input id="hd-create-confirm" class="peach-underline-input peach-unlock-input"925-      type="password" placeholder="confirm password" autocomplete="new-password">926-927-    <!-- Strength bar (new in 1.4.12 — uses shared peach-password-strength classes) -->928-    <div class="peach-password-strength" id="hd-create-strength" style="display:none">929-      <div class="peach-strength-bar peach-strength-bar--fair">930-        <div class="peach-strength-fill"></div>931-      </div>932-      <span class="peach-strength-label">fair</span>933-    </div>934-935-    <button class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit" id="hd-create-btn" disabled>936-      create vault937-    </button>938-    <p class="peach-hint">If you lose this password, Peach cannot recover your vault.</p>939-940-    <div class="peach-lock-panel-footer">941-      <p class="peach-hint">Already have a backup?</p>942-      <div class="peach-onboarding-alt-actions">943-        <button class="peach-lock-restore-link">restore from CJK phrase</button>944-        <button class="peach-lock-restore-link">import from backup file</button>945-      </div>946-    </div>947-  </section>948-</section>949-```950-951-### H — Interactive behaviors952-953-| Action | Effect |954-|--------|--------|955-| Type in master password | Show strength bar; animate fill; update label (weak/fair/good/strong) |956-| Both fields match, ≥8 chars | Enable "create vault" button |957-| Click "create vault" | Show "creating…" then transition to Surface A |958-959----960-961-## 15. JavaScript interactivity sketch962-963-All demo interactivity lives in a single `<script>` tag at the bottom of `HeroDemo.astro`. Keep it simple — no framework.964-965-```js966-// Surface routing967-const surfaces = {968-  detail: document.getElementById('hd-shell'),969-  list:   document.getElementById('hd-view-list'),970-  add:    document.getElementById('hd-view-new'),971-  settings: document.getElementById('hd-view-settings'),972-  health: document.getElementById('hd-view-health'),973-  lock:   document.getElementById('hd-view-lock'),974-  onboarding: document.getElementById('hd-view-onboarding'),975-};976-977-let activeSurface = 'detail';978-979-function show(id) {980-  // Add ov-enter-up / ov-enter-right class on show to trigger animation981-  Object.values(surfaces).forEach(s => s && (s.style.display = 'none'));982-  const el = surfaces[id];983-  if (el) {984-    el.style.display = 'flex';985-    // Restart animation986-    el.classList.remove('ov-enter-up', 'ov-enter-right');987-    void el.offsetWidth;988-    if (id === 'list') el.classList.add('ov-enter-up');989-    else if (['add','settings','health'].includes(id)) el.classList.add('ov-enter-right');990-    activeSurface = id;991-  }992-}993-994-// Copy animation995-function animateCopy(btn) {996-  btn.classList.add('is-copied');997-  setTimeout(() => btn.classList.remove('is-copied'), 1500);998-}999-1000-// TOTP countdown1001-let totpInterval = null;1002-let totpRemaining = 18;1003-1004-function startTotpCountdown() {1005-  clearInterval(totpInterval);1006-  totpInterval = setInterval(() => {1007-    totpRemaining--;1008-    if (totpRemaining <= 0) {1009-      totpRemaining = 30;1010-      // Generate new fake code1011-      const newCode = String(Math.floor(100000 + Math.random() * 900000));1012-      const formatted = newCode.slice(0,3) + ' ' + newCode.slice(3);1013-      document.getElementById('hd-totp-code')?.textContent = formatted;1014-    }1015-    const el = document.getElementById('hd-totp-remaining');1016-    if (el) el.textContent = totpRemaining + 's';1017-    // Update ring1018-    const circumference = 106.8;1019-    const progress = totpRemaining / 30;1020-    const offset = circumference * (1 - progress);1021-    document.querySelector('.peach-totp-ring-progress')1022-      ?.style.setProperty('stroke-dashoffset', offset);1023-    document.querySelector('.peach-totp-progress-bar')1024-      ?.style.setProperty('width', (progress * 100) + '%');1025-  }, 1000);1026-}1027-1028-// Password strength (simple heuristic for demo)1029-function getStrength(pw) {1030-  if (!pw) return null;1031-  let s = 0;1032-  if (pw.length >= 8) s++;1033-  if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;1034-  if (/\d/.test(pw)) s++;1035-  if (/[^A-Za-z0-9]/.test(pw)) s++;1036-  const labels = ['weak','fair','fair','good','strong'];1037-  const widths  = ['20%','40%','55%','75%','100%'];1038-  return { label: labels[s], width: widths[s], cls: labels[s] };1039-}1040-1041-// Wire up all the event handlers here...1042-// (full handler list per section above)1043-```1044-1045----1046-1047-## 16. Feature callouts beneath the demo1048-1049-Below the interactive popup, add a row of feature callout chips that reinforce what was just seen. Each chip links to the relevant section on the page:1050-1051-```html1052-<div class="hd-feature-chips">1053-  <span class="hd-chip">🔐 AES-256-GCM encryption</span>1054-  <span class="hd-chip">🛡 HIBP breach monitoring</span>1055-  <span class="hd-chip">🗝 Passkeys (WebAuthn)</span>1056-  <span class="hd-chip">🟠 Canary honeypots</span>1057-  <span class="hd-chip">🈶 CJK phrase backup</span>1058-  <span class="hd-chip">🔄 Peach Sync (Pro)</span>1059-  <span class="hd-chip">📊 Vault Health</span>1060-  <span class="hd-chip">💡 Duress password</span>1061-</div>1062-```1063-1064----1065-1066-## 17. Static screenshot surfaces1067-1068-The following surfaces are too branched to demo interactively but should appear as static annotated screenshots in a "More features" section or carousel on the page:1069-1070-1. **Passkey approval** — the pop-up that appears when a site requests a passkey (`save a passkey?` / `sign in with passkey?`)1071-2. **CJK export** — the 2,000+ character Chinese Ideograph backup phrase output1072-3. **CJK import** — the phrase validation flow with HIBP check1073-4. **Sync pairing** — the 6-digit pairing code UI1074-5. **Autofill in-page** — screenshot of the injected Peach icon on a login field + identity-helper dropdown1075-6. **Duress password setup** — the settings flow for configuring the decoy vault1076-1077-Produce these screenshots by loading the real built extension in a screenshot-capable Chrome instance.1078-1079----1080-1081-## 18. Accessibility requirements1082-1083-The demo is focusable and keyboard-navigable:1084-- All interactive buttons have `aria-label` attributes1085-- Copy confirmation is announced via `aria-live="polite"` region1086-- Vault list rows are `<button>` elements (already the case in real code)1087-- Password reveal toggle updates `aria-label` to reflect state ("Reveal password" / "Hide password")1088-- TOTP countdown is wrapped in `aria-live="polite"` so screen readers can announce refresh1089-1090----1091-1092-## 19. Sync checklist — keeping demo current1093-1094-After every extension release, verify these items before shipping the website update:1095-1096-- [ ] CSS — re-copy `vars.css` and `popup.css` from extension source1097-- [ ] Version number in Surface E footer1098-- [ ] Settings group order matches real Settings.tsx (currently: security → duress → passkeys → sync → vault → license)1099-- [ ] Sort bar labels match real Vault.tsx (`smart`, `A–Z`, `Z–A`, `newest`)1100-- [ ] Strength bar classes used in Surface H match real Lock.tsx (`peach-strength-bar--weak/fair/good/strong`)1101-- [ ] Feature chip list reflects any new features added in the release1102-- [ ] TOTP code animation ring physics (`stroke-dasharray: 106.8` matches `r=17`, circumference = 2π×17 ≈ 106.8)

view file at the parent

peach_faithful_spec.md+0 −1252

not shown here: too long for one page

view file at the parent

peach_gaps_spec.md+0 −813

not shown here: too long for one page

view file at the parent

peach_icon_injection_spec.md+0 −235
@@ -1,235 +0,0 @@1-# Peach Icon Injection — Reliability Fix Spec2-_Peach v1.4.0 target_3-4----5-6-## Diagnosis7-8-After reading `src/content/inject.ts`, `src/content/detect.ts`, and `src/content/index.ts` in full, there are five distinct failure modes that together explain why the icon misses on some sites.9-10----11-12-### Failure 1 — Icon is positioned relative to the parent element (the main culprit)13-14-`injectFieldIcon` does this:15-16-```ts17-const parent = field.parentElement as HTMLElement;18-parent.style.position = "relative";19-parent.appendChild(svgEl);20-```21-22-The SVG then uses `position: absolute; right: 6px; top: 50%`. This works when the parent is a simple wrapper that visually hugs the input. It breaks in all of these common cases:23-24-- Parent has `overflow: hidden` → icon is clipped25-- Parent is a flex row or grid cell whose bounds don't match the visible input → icon appears in the wrong position or outside the field26-- Parent already has `position: fixed` or `position: sticky` (sticky login headers) → adding `position: relative` removes the sticky behavior and the icon disappears with the sticky element27-- Parent is a shadow host or a custom element — `parentElement` returns null or an element the code can't safely mutate28-- Site JS watches for mutations on the parent and removes unknown children (common in React-rendered forms), causing the icon to vanish shortly after injection29-30-**The fix:** use `position: fixed` anchored to `getBoundingClientRect()` and track the field position with `requestAnimationFrame`, exactly like `showAutofillSelector` already does. The icon becomes a direct child of `document.body` and is completely immune to parent layout and `overflow: hidden`.31-32-```ts33-export function injectFieldIcon(34-  field: HTMLInputElement,35-  onClick: () => void36-): () => void {37-  const icon = document.createElement("div");38-  icon.innerHTML = peachIconSvg();39-  const svgEl = icon.firstElementChild as HTMLElement;40-41-  svgEl.style.cssText = `42-    position: fixed;43-    z-index: 2147483646;44-    width: 18px;45-    height: 18px;46-    cursor: pointer;47-    pointer-events: auto;48-    border-radius: 50%;49-    transition: opacity 120ms ease;50-    opacity: 0;51-  `;52-53-  svgEl.addEventListener("click", (e) => {54-    e.stopPropagation();55-    e.preventDefault();56-    onClick();57-  });58-59-  document.body.appendChild(svgEl);60-61-  // Position the icon at the right edge of the field, vertically centered.62-  function updatePosition() {63-    const rect = field.getBoundingClientRect();64-    if (rect.width === 0 || rect.height === 0) {65-      // Field collapsed or off-screen — hide icon66-      svgEl.style.opacity = "0";67-      svgEl.style.pointerEvents = "none";68-    } else {69-      svgEl.style.top = `${rect.top + rect.height / 2 - 9}px`;70-      svgEl.style.left = `${rect.right - 26}px`;71-      svgEl.style.opacity = "1";72-      svgEl.style.pointerEvents = "auto";73-    }74-  }75-76-  // Fade in after first position is set77-  updatePosition();78-  requestAnimationFrame(() => { svgEl.style.opacity = "1"; });79-80-  let rafId = requestAnimationFrame(function loop() {81-    updatePosition();82-    rafId = requestAnimationFrame(loop);83-  });84-85-  // Add padding so the icon doesn't overlap text the user is typing86-  const originalPaddingRight = field.style.paddingRight;87-  const currentPad = parseInt(getComputedStyle(field).paddingRight) || 0;88-  field.style.paddingRight = `${currentPad + 26}px`;89-90-  return () => {91-    cancelAnimationFrame(rafId);92-    svgEl.remove();93-    field.style.paddingRight = originalPaddingRight;94-  };95-}96-```97-98-The `requestAnimationFrame` loop is cheap — it only reads `getBoundingClientRect()` and sets two style properties. `showAutofillSelector` already runs the same pattern continuously with no performance issues.99-100----101-102-### Failure 2 — No initial scan at startup103-104-The content script sets up `focusin` listening and `MutationObserver` but never scans the existing DOM. If the page renders its login form before `document_idle` fires (most static sites, server-rendered pages) and the DOM doesn't change again, the `MutationObserver` never fires and the icon only appears on the user's first focus.105-106-This is acceptable for the selector popup (it triggers on focus anyway) but means the icon never appears until after the first interaction, which makes the feature feel broken — users see the popup on focus but there's no icon hinting that Peach is available before they touch the field.107-108-**The fix:** run an initial scan after a short delay to allow SPA hydration, and again at progressively longer intervals for slow-loading SPAs.109-110-```ts111-// At the bottom of index.ts, after setupMutationObserver():112-113-// Initial scan — runs once at idle, then retries for SPAs that hydrate late114-function initialScan() {115-  const newFields = detectLoginFields().filter(116-    (f) => f.type === "password" && !processedInputs.has(f.el)117-  );118-  for (const f of newFields) {119-    reattachListeners(f.el);120-  }121-}122-123-// Run at 0ms, 500ms, 1500ms — catches static pages, React hydration, lazy-loaded widgets124-setTimeout(initialScan, 0);125-setTimeout(initialScan, 500);126-setTimeout(initialScan, 1500);127-```128-129----130-131-### Failure 3 — `processedInputs` WeakSet marks a field as done even if the icon was removed132-133-Once `processedInputs.add(passwordEl)` fires, that field is never re-processed — even if site JS removes the icon from the DOM. On React/Vue sites that re-render form components on validation errors or transitions, the icon silently disappears and never comes back.134-135-**The fix:** switch from `WeakSet<HTMLInputElement>` to `WeakMap<HTMLInputElement, () => void>` that stores the cleanup function. On each `debouncedReattach` pass, check whether the stored cleanup element is still in the DOM — if not, run cleanup and re-inject.136-137-```ts138-// Replace:139-const processedInputs = new WeakSet<HTMLInputElement>();140-141-// With:142-const processedInputs = new WeakMap<HTMLInputElement, () => void>();143-```144-145-In `reattachListeners`:146-```ts147-function reattachListeners(el: HTMLInputElement): void {148-  // If already processed, check if the icon is still attached149-  if (processedInputs.has(el)) {150-    // Nothing to do — the rAF loop in injectFieldIcon handles repositioning151-    return;152-  }153-154-  // ... rest of injection155-  const cleanup = injectFieldIcon(el, () => { ... });156-  processedInputs.set(el, cleanup);157-}158-```159-160-In `debouncedReattach`, before running `reattachListeners`, check for stale entries:161-```ts162-const debouncedReattach = debounce(() => {163-  const fields = detectLoginFields();164-  for (const f of fields) {165-    if (f.type !== "password") continue;166-    reattachListeners(f.el);167-  }168-}, 200); // increased from 120ms169-```170-171-And in `onFocusIn`, replace `processedInputs.has(passwordEl)` / `processedInputs.add(passwordEl)` with the WeakMap equivalent.172-173----174-175-### Failure 4 — `isLoginForm` gate is too strict for multi-step flows176-177-`onFocusIn` returns early if `!isLoginForm(fields) && !isSignupForm(fields)`. Many modern sites show password-only forms (step 2 of a username-first flow), or email-only forms (magic link). These never pass `isLoginForm` because there's no username+password pair on screen simultaneously, so the icon never appears.178-179-`isLoginForm` requires either (username + password) or (single password with `autocomplete="current-password"`). The first step of a username→password split flow never satisfies this.180-181-**The fix:** for the icon injection specifically, relax to: any visible `input[type="password"]` gets the icon. The `isLoginForm` check can still gate the autofill selector popup (which requires knowing which entries to show), but the icon itself should appear on any password field the user focuses.182-183-```ts184-// In onFocusIn, split the logic:185-186-// 1. Show icon for any password field, regardless of form shape187-if (fieldType === "password" && !processedInputs.has(field)) {188-  const cleanup = injectFieldIcon(field, () => {189-    currentDismiss?.();190-    onFocusIn({ target: field } as unknown as Event);191-  });192-  processedInputs.set(field, cleanup);193-}194-195-// 2. Only show the selector popup if it looks like a login/signup form AND there are matching entries196-if (!isLoginForm(fields) && !isSignupForm(fields)) return;197-// ... rest of selector logic198-```199-200----201-202-### Failure 5 — ML prediction gates username field injection, 150ms timeout is often hit203-204-The ML model is loaded lazily and the first prediction takes longer than 150ms on a cold start (ONNX WASM instantiation). The code resolves `null` after timeout, then checks:205-206-```ts207-if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {208-  return;209-}210-```211-212-When `mlPrediction` is `null` (timeout), this condition is false (because `mlPrediction && ...` is false), so the code proceeds. That's actually fine. But the real problem is that on the username step of a split login (no password field visible), the code hits the `if (!pwField) return;` guard shortly after and bails. The icon injection for step-1 (username-only) fields never happens.213-214-**The fix:** in the relaxed model from Failure 4 above, inject the icon for the focused field as soon as it looks like a credential field (password, or ML-predicted username/email) — before checking for a complete login form. The form completeness check only gates the selector popup.215-216----217-218-## Summary of changes219-220-| File | Change |221-|------|--------|222-| `src/content/inject.ts` | `injectFieldIcon` rewritten to use `position: fixed` + `getBoundingClientRect()` + `requestAnimationFrame` position loop; returns cleanup function that cancels the rAF loop and removes the SVG from body |223-| `src/content/index.ts` | `processedInputs` → `WeakMap<HTMLInputElement, () => void>`; add `initialScan()` with 0/500/1500ms retries; relax icon injection to any password field (decouple from `isLoginForm` check); debounce raised from 120ms to 200ms; `reattachListeners` updated for WeakMap |224-225-**No changes to `detect.ts`, `fill.ts`, `save.ts`, or any background files.**226-227----228-229-## What this does NOT change230-231-- The autofill selector popup logic is unchanged — it still only appears when there are matching vault entries and the form passes the login/signup detection232-- The canary ping on autofill is unchanged233-- The TOTP autofill is unchanged234-- The identity helper for signup forms is unchanged235-- The ML detector is unchanged — it still gates the username field selector popup, just not the icon itself

view file at the parent

peach_passkeys_spec.md+0 −669

not shown here: too long for one page

view file at the parent

peach_shine_spec.md+0 −624

not shown here: too long for one page

view file at the parent

peach_sync_spec.md+0 −407

not shown here: too long for one page

view file at the parent

peach_vault_integrity_spec.md+0 −824

not shown here: too long for one page

view file at the parent

scripts/clean-chrome-dist.mjs+15 −0
@@ -0,0 +1,15 @@1+import { existsSync, rmSync } from "node:fs";2+import path from "node:path";3+import { fileURLToPath } from "node:url";4+5+const __filename = fileURLToPath(import.meta.url);6+const __dirname = path.dirname(__filename);7+const rootDir = path.resolve(__dirname, "..");8+9+for (const dir of ["dist-chrome", "dist"]) {10+  const target = path.join(rootDir, dir);11+  if (existsSync(target)) {12+    rmSync(target, { recursive: true, force: true });13+    console.log(`Cleaned ${dir}`);14+  }15+}

view file

scripts/clean-firefox-artifacts.mjs+4 −4
@@ -5,9 +5,9 @@55 const __filename = fileURLToPath(import.meta.url);66 const __dirname = path.dirname(__filename);77 const rootDir = path.resolve(__dirname, "..");8-const artifactsDir = path.join(rootDir, "dist-firefox", "web-ext-artifacts");8+const distDir = path.join(rootDir, "dist-firefox");99 10-if (existsSync(artifactsDir)) {11-  rmSync(artifactsDir, { recursive: true, force: true });12-  console.log(`Removed stale Firefox package artifacts from ${path.relative(rootDir, artifactsDir)}`);10+if (existsSync(distDir)) {11+  rmSync(distDir, { recursive: true, force: true });12+  console.log(`Cleaned ${path.relative(rootDir, distDir)}`);1313 }

view file

status.md+0 −193
@@ -1,193 +0,0 @@1-# Peach — Project Status2-3-**Generated:** 2026-04-19  4-**Version:** 1.0.4  5-**Type:** Chrome Extension (Manifest V3)6-7----8-9-## Build Status10-11-| Check | Status |12-|-------|--------|13-| TypeScript | ✅ Passing (`tsc --noEmit`) |14-| Unit Tests | ✅ 6/6 passing (vitest) |15-| Vite Build | ✅ Built (`dist/`) |16-| Extension ID | `peach-browser` |17-18----19-20-## Architecture21-22-```23-src/24-├── background/      Service worker (vault + message handling)25-│   ├── entry.ts     Entry point26-│   ├── aes.ts       Background-only AES helpers to avoid popup bundle leakage27-│   ├── vault.ts     Vault CRUD + crypto ops (in-memory key)28-│   ├── messages.ts  Message handler bridge29-│   ├── clipboard.ts Auto-clear clipboard after 30s30-│   └── alarms.ts    Auto-lock after 15 min idle31-├── content/         Injected into all pages32-│   ├── index.ts     Detects forms, injects 🍑 icon33-│   ├── detect.ts    Field detection (username/password)34-│   ├── fill.ts      Field filling via setNativeValue35-│   ├── inject.ts    Autofill selector UI (shadow DOM)36-│   └── save.ts      Detects form submits, prompts to save37-├── popup/           Extension popup UI (Solid.js)38-│   ├── App.tsx      Root component, view router39-│   ├── views/       Lock, Vault, Entry, Add, Settings, Import, Export, Generator40-│   └── components/  TOTP, Ring, Pip, Generator41-├── crypto/          Zero-knowledge crypto42-│   ├── argon2.ts    Key derivation (Argon2id via WASM)43-│   ├── aes.ts       AES-256-GCM encrypt/decrypt44-│   ├── totp.ts      TOTP generation (otpauth)45-│   └── crypto.test.ts46-└── shared/          Shared types, domain extraction, import parsers47-```48-49----50-51-## Feature Inventory52-53-### Vault Core54-- [x] Create vault with master password (min 8 chars)55-- [x] Unlock vault (Argon2id → AES-GCM)56-- [x] Lock vault (zero key from memory)57-- [x] Auto-lock on idle / alarm58-- [x] Popup/background message timeout path for service worker unavailability59-- [x] Reopen popup shows unlock or active vault instead of falling back to first-run create state60-- [x] Detail-first PeachV2 popup shell with slide-in overlays for list, settings, new entry, and generator61-62-### Entry Management63-- [x] Add entry (title, username, password, URL, notes, TOTP)64-- [x] Edit entry65-- [x] Soft delete entries (`deletedAt`) with filtered active views66-- [x] Search/filter vault entries67-- [x] Copy username / password (with visual/audio feedback)68-- [x] Clipboard auto-clear after 30s69-- [x] Stable `createdAt` / `modifiedAt` timestamps with legacy migration70-71-### Autofill (Content Script)72-- [x] Inject 🍑 icon on detected password fields73-- [x] Domain-filtered entry matching (eTLD+1)74-- [x] Shadow DOM selector popup on icon click75-- [x] Fill username + password on selection76-- [x] Field visibility check before fill77-78-### Save Detection (Content Script)79-- [x] Intercept form submits80-- [x] Detect username + password fields81-- [x] Show save/update banner (shadow DOM)82-- [x] Create new entry or update existing83-84-### Import / Export85-- [x] CSV import (PapaParse)86-- [x] 1Password JSON import87-- [x] Encrypted JSON export88-89-### Password Generator90-- [x] Generate random passwords91-- [x] Use in new entry flow92-93-### TOTP94-- [x] Parse otpauth:// URIs95-- [x] Display 30s countdown timer in entry view96-97-### UI / Visual System98-- [x] Self-hosted popup fonts (`Cormorant Garamond`, `IBM Plex Mono`, `Inter`) in `public/fonts/`99-- [x] Static pre-rendered lock screen HTML in popup entrypoint before Solid mounts100-- [x] Archive-style chrome, ghost title, mono field rows, bottom search bar, and overlay list101-102----103-104-## Crypto Implementation105-106-| Concern | Implementation |107-|---------|----------------|108-| Key derivation | Argon2id (hash-wasm) — 3 iterations, 64MB memory |109-| Encryption | AES-256-GCM — fresh nonce per write |110-| Auth tag | 128-bit GCM tag, stored alongside ciphertext |111-| AAD | Version byte `0x01` prevents downgrade |112-| Key zeroing | `Uint8Array.fill(0)` on lock |113-114----115-116-## Test Status117-118-**TEST_CHECKLIST.md has 22 manual tests defined, 0 marked complete.**119-120-Recommended manual tests before daily driving:121-1. Vault create/unlock/lock cycle122-2. Entry CRUD + persistence across locks123-   Includes popup close/reopen while the background may still be alive or already suspended124-3. Autofill on github.com (domain filter, manual trigger, visible fill)125-4. TOTP display and countdown126-5. Password generator127-6. Import CSV / Export encrypted backup128-7. Clipboard auto-clear (30s wait)129-8. Auto-lock on idle130-9. Save credential detection on form submit131-10. Wrong password → shake animation, clean error132-133-**Release blockers to verify before daily driving:**134-- [ ] No wrong domain filled135-- [ ] No silent autofill (icon click required)136-- [ ] No fill into hidden fields137-- [ ] Vault locks cleanly (key zeroed)138-- [ ] Import/export round-trips correctly139-- [ ] No console.log in crypto paths140-141----142-143-## Known Gaps144-145-| Gap | Status |146-|-----|--------|147-| HIBP breach check | Defined in types (`breach` field) but not implemented |148-| Backup restore test | Checked as release blocker, not automated |149-| Unit tests for content script | None |150-| Unit tests for vault CRUD | Basic create + soft-delete coverage added; broader CRUD coverage still missing |151-| E2E / Playwright tests | Manual checklist still primary, but `test-extension.mjs` now exercises extension loading, vault creation, entry save, and popup reopen persistence |152-153----154-155-## Dependencies156-157-| Package | Purpose |158-|---------|---------|159-| `hash-wasm` | Argon2id WASM implementation |160-| `otpauth` | TOTP code generation |161-| `papaparse` | CSV import |162-| `tldts` | eTLD+1 domain extraction |163-| `webextension-polyfill` | Cross-browser extension API |164-| `solid-js` | Popup UI framework |165-| `vite-plugin-solid` | Solid.js Vite integration |166-| `@crxjs/vite-plugin` | CRX packaging for Vite |167-| `vitest` | Unit testing |168-169----170-171-## Security Considerations172-173-- Vault is stored encrypted in `chrome.storage.local`174-- Master password never stored — derived key held in memory only while unlocked175-- Service worker restart = vault locks (expected MV3 behavior)176-- Background bundle is isolated from popup-only chunks so MV3 service worker startup no longer crashes on `document` access177-- Popup boot now waits for `HAS_VAULT` / `IS_UNLOCKED` before choosing create vs unlock, preventing false first-run screens after reopen178-- Popup fonts are now self-hosted instead of loaded from Google Fonts at runtime179-- Content script runs on all URLs but autofill requires explicit user click180-- Shadow DOM used for selector/banner to avoid page CSS conflicts181-182----183-184-## Build Instructions185-186-```bash187-npm run dev      # Dev server with hot reload188-npm run build    # Production build → dist/189-npm run typecheck # TypeScript validation190-npm test         # Unit tests (vitest)191-```192-193-To load in Chrome: `chrome://extensions` → Developer mode → Load unpacked → `dist/`

view file at the parent

test-extension.mjs+0 −139
@@ -1,139 +0,0 @@1-import { chromium } from "playwright";2-import fs from "fs/promises";3-import os from "os";4-import path from "path";5-import { fileURLToPath } from "url";6-7-const __filename = fileURLToPath(import.meta.url);8-const __dirname = path.dirname(__filename);9-const EXTENSION_PATH = path.join(__dirname, "dist");10-11-async function main() {12-  const userDataDir = path.join(13-    os.tmpdir(),14-    `peach-playwright-profile-${Date.now()}`15-  );16-  await fs.mkdir(userDataDir, { recursive: true });17-18-  const context = await chromium.launchPersistentContext(userDataDir, {19-    headless: false,20-    args: [21-      `--disable-extensions-except=${EXTENSION_PATH}`,22-      `--load-extension=${EXTENSION_PATH}`,23-      "--no-sandbox",24-      "--disable-setuid-sandbox",25-    ],26-  });27-28-  const page = await context.newPage();29-30-  // Capture console messages31-  page.on("console", (msg) => {32-    console.log(`[console.${msg.type()}] ${msg.text()}`);33-  });34-35-  page.on("pageerror", (err) => {36-    console.log(`[pageerror] ${err.message}`);37-  });38-39-  // Click the extension icon to open popup40-  let [serviceWorker] = context.serviceWorkers();41-  if (!serviceWorker) {42-    serviceWorker = await context.waitForEvent("serviceworker");43-  }44-45-  const extId = new URL(serviceWorker.url()).host;46-  console.log(`Extension ID: ${extId}`);47-48-  const popup = await context.newPage();49-  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);50-51-  popup.on("console", (msg) => {52-    console.log(`[popup console.${msg.type()}] ${msg.text()}`);53-  });54-55-  popup.on("pageerror", (err) => {56-    console.log(`[popup pageerror] ${err.message}`);57-  });58-59-  // Wait for the page to load60-  await popup.waitForTimeout(2000);61-62-  const title = await popup.title();63-  console.log(`Popup title: ${title}`);64-65-  const createButton = popup.getByRole("button", { name: "create vault" });66-  const unlockButton = popup.getByRole("button", { name: "unlock" });67-68-  if (await createButton.isVisible()) {69-    console.log("\nCreating fresh vault...");70-    await popup.locator("#master-password").fill("testpassword123");71-    await popup.locator("#confirm-password").fill("testpassword123");72-    await createButton.click();73-    await popup.waitForTimeout(1500);74-  }75-76-  const newEntryButton = popup.getByRole("button", {77-    name: "New entry",78-    exact: true,79-  });80-  await newEntryButton.click();81-  console.log("New entry form opened");82-83-  await popup.locator(".peach-new-name-input").fill("GitHub");84-  await popup.locator('input[placeholder="domain.com"]').fill("https://github.com");85-  await popup.locator('input[placeholder="username or email"]').fill("june");86-  await popup.locator('input[placeholder="password"]').fill("supersecret123!");87-  await popup.getByRole("button", { name: "create" }).click();88-  console.log("Entry created");89-90-  await popup.waitForTimeout(1000);91-  console.log(92-    `Entry visible after save: ${await popup.locator("text=GitHub").first().isVisible()}`93-  );94-95-  await popup.close();96-  console.log("Popup closed to simulate clicking away");97-98-  const reopenedPopup = await context.newPage();99-  await reopenedPopup.goto(`chrome-extension://${extId}/src/popup/index.html`);100-  await reopenedPopup.waitForTimeout(1500);101-102-  const createVisibleAfterReopen = await reopenedPopup103-    .getByRole("button", { name: "create vault" })104-    .isVisible()105-    .catch(() => false);106-  const unlockVisibleAfterReopen = await reopenedPopup107-    .getByRole("button", { name: "unlock" })108-    .isVisible()109-    .catch(() => false);110-  const entryVisibleAfterReopen = await reopenedPopup111-    .locator("text=GitHub")112-    .first()113-    .isVisible()114-    .catch(() => false);115-116-  console.log(`Create vault visible after reopen: ${createVisibleAfterReopen}`);117-  console.log(`Unlock visible after reopen: ${unlockVisibleAfterReopen}`);118-  console.log(`Entry visible after reopen: ${entryVisibleAfterReopen}`);119-120-  if (!entryVisibleAfterReopen && unlockVisibleAfterReopen) {121-    const reopenedUnlockButton = reopenedPopup.getByRole("button", {122-      name: "unlock",123-    });124-    await reopenedPopup.locator("#master-password").fill("testpassword123");125-    await reopenedUnlockButton.click();126-    await reopenedPopup.waitForTimeout(1500);127-    console.log(128-      `Entry still present after unlock: ${await reopenedPopup129-        .locator("text=GitHub")130-        .first()131-        .isVisible()}`132-    );133-  }134-135-  await context.close();136-  console.log("\nDone.");137-}138-139-main().catch(console.error);

view file at the parent