# Peach v1.4.10 — Codebase Audit & State of the Union _Every source file read, every message handler traced, every security surface examined._ --- ## 0. Verdict 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. --- ## 1. Files Audited | Layer | Files | |---|---| | 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 | | Content | index.ts, inject.ts, detect.ts, fill.ts, save.ts | | Crypto | totp.ts, argon2.ts (path traced), aes.ts | | Shared | types.ts, generate.ts, domain.ts, sync-types.ts | | Popup | App.tsx, Entry.tsx (key sections) | | Build | manifest.json, CONTINUITY.md | --- ## 2. Critical Bugs (fix before daily driving) ### C-1: Site password policy is permanently broken **File:** `src/background/messages.ts` line 222 **File:** `src/popup/views/GeneratorDrawer.tsx` (caller) ```ts case "GET_SITE_PASSWORD_POLICY": return { ok: true, data: null }; // always returns null ``` 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. **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. **Fix:** In `messages.ts`, forward `GET_SITE_PASSWORD_POLICY` to the active tab's content script: ```ts case "GET_SITE_PASSWORD_POLICY": { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) return { ok: true, data: null }; try { const result = await chrome.tabs.sendMessage(tab.id, { type: "GET_SITE_PASSWORD_POLICY" }); return result; } catch { return { ok: true, data: null }; } } ``` --- ## 3. High Priority (fix this week) ### H-1: Autofill icon not re-injected after SPA navigation **File:** `src/content/index.ts`, `processedInputs` WeakMap 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. However, the `debouncedReattach` function only fires for **password** fields: ```ts const newPasswords = detectLoginFields().filter( (f) => f.type === "password" && !processedInputs.has(f.el) ); ``` 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. **Fix:** Change the filter to include `username` and `email` types, matching the same criteria used in `onFocusIn`. --- ### H-2: TOTP clipboard write silently fails on unfocused pages **File:** `src/content/index.ts` line 260 ```ts await navigator.clipboard.writeText(code); ``` 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. **Fix:** Send a `COPY` message to the background instead: ```ts chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP" } }); ``` The background `copyToClipboard()` function uses `chrome.scripting.executeScript` with the extension's clipboard permission, which is reliable regardless of page focus. --- ### H-3: All entries sent to content script when no domain match **File:** `src/content/index.ts` lines 361–362 ```ts const entries = await fetchAutofillEntries(domain); const allEntries = entries.length === 0 ? await fetchAutofillEntries(domain, true) : entries; ``` 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. 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. The TOTP secrets are particularly sensitive: they're the raw base32 seeds, not generated codes. **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. Alternatively: only send `allEntries` for the search header display (which needs titles), and strip `totp` unconditionally. --- ### H-4: Idle lock fires after 60 seconds of inactivity **File:** `src/background/alarms.ts` line 22 ```ts chrome.idle.setDetectionInterval(60); // 60 seconds ``` **File:** `src/background/alarms.ts` lines 29–32 ```ts chrome.idle.onStateChanged.addListener((state) => { if (state === "idle" || state === "locked") lock(); }); ``` 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. 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. **Fix:** ```ts chrome.idle.setDetectionInterval(5 * 60); // 5 minutes, or pull from settings ``` --- ## 4. Security Findings ### S-1: No URL validation on canary webhook **File:** `src/background/messages.ts` lines 177–193 `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. **Fix:** Validate that `webhookUrl` is an `https://` URL before fetching. Reject anything with a private IP or `localhost`. --- ### S-2: License key stored unencrypted **File:** `src/background/license.ts` 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. This is low-severity (the crypto is correct) but worth noting. --- ### S-3: Settings stored unencrypted **File:** `src/background/vault.ts`, `persistSettings()` 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. --- ### S-4: E2E passkey bypass in production build (verify) **File:** `src/background/passkey.ts` ```ts const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true; ``` 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. --- ### S-5: Service worker restart drops passkey approval callbacks **File:** `src/background/passkey.ts` 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. 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. --- ## 5. Functional Gaps ### F-1: Sync is architecture-only, not wired **Files:** `src/background/messages.ts`, `src/background/sync.ts` The message handlers for `TRIGGER_SYNC`, `START_PAIRING`, `CANCEL_PAIRING`, and `ACCEPT_PAIRING` all return `{ ok: true }` without calling anything: ```ts case "TRIGGER_SYNC": return { ok: true }; // no-op case "START_PAIRING": return { ok: true }; // no-op ``` `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. The sync architecture is sound. Wiring it up is the remaining work. --- ### F-2: Save detection misses SPA form submissions **File:** `src/content/save.ts` Save detection listens for `document.addEventListener("submit", ...)`. Modern SPAs (Gmail, Notion, most React apps) intercept the submit event or don't use `