peach_audit_v1410.md

18.5 KB · raw

rendered · source

1# Peach v1.4.10 — Codebase Audit & State of the Union23_Every source file read, every message handler traced, every security surface examined._45---67## 0. Verdict89Peach 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.1011---1213## 1. Files Audited1415| 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 |2324---2526## 2. Critical Bugs (fix before daily driving)2728### C-1: Site password policy is permanently broken2930**File:** `src/background/messages.ts` line 222  31**File:** `src/popup/views/GeneratorDrawer.tsx` (caller)3233```ts34case "GET_SITE_PASSWORD_POLICY":35  return { ok: true, data: null };   // always returns null36```3738The 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.3940**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.4142**Fix:** In `messages.ts`, forward `GET_SITE_PASSWORD_POLICY` to the active tab's content script:4344```ts45case "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```5657---5859## 3. High Priority (fix this week)6061### H-1: Autofill icon not re-injected after SPA navigation6263**File:** `src/content/index.ts`, `processedInputs` WeakMap6465When 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.6667However, the `debouncedReattach` function only fires for **password** fields:6869```ts70const newPasswords = detectLoginFields().filter(71  (f) => f.type === "password" && !processedInputs.has(f.el)72);73```7475Username/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.7677**Fix:** Change the filter to include `username` and `email` types, matching the same criteria used in `onFocusIn`.7879---8081### H-2: TOTP clipboard write silently fails on unfocused pages8283**File:** `src/content/index.ts` line 2608485```ts86await navigator.clipboard.writeText(code);87```8889The 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.9091**Fix:** Send a `COPY` message to the background instead:9293```ts94chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP" } });95```9697The background `copyToClipboard()` function uses `chrome.scripting.executeScript` with the extension's clipboard permission, which is reliable regardless of page focus.9899---100101### H-3: All entries sent to content script when no domain match102103**File:** `src/content/index.ts` lines 361–362104105```ts106const entries = await fetchAutofillEntries(domain);107const allEntries = entries.length === 0 ? await fetchAutofillEntries(domain, true) : entries;108```109110When `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.111112This 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.113114The TOTP secrets are particularly sensitive: they're the raw base32 seeds, not generated codes.115116**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.117118Alternatively: only send `allEntries` for the search header display (which needs titles), and strip `totp` unconditionally.119120---121122### H-4: Idle lock fires after 60 seconds of inactivity123124**File:** `src/background/alarms.ts` line 22125126```ts127chrome.idle.setDetectionInterval(60);   // 60 seconds128```129130**File:** `src/background/alarms.ts` lines 29–32131132```ts133chrome.idle.onStateChanged.addListener((state) => {134  if (state === "idle" || state === "locked") lock();135});136```137138The 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.139140The 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.141142**Fix:**143```ts144chrome.idle.setDetectionInterval(5 * 60); // 5 minutes, or pull from settings145```146147---148149## 4. Security Findings150151### S-1: No URL validation on canary webhook152153**File:** `src/background/messages.ts` lines 177–193154155`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.156157**Fix:** Validate that `webhookUrl` is an `https://` URL before fetching. Reject anything with a private IP or `localhost`.158159---160161### S-2: License key stored unencrypted162163**File:** `src/background/license.ts`164165The 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.166167This is low-severity (the crypto is correct) but worth noting.168169---170171### S-3: Settings stored unencrypted172173**File:** `src/background/vault.ts`, `persistSettings()`174175Theme, 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.176177---178179### S-4: E2E passkey bypass in production build (verify)180181**File:** `src/background/passkey.ts`182183```ts184const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;185```186187If `__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.188189---190191### S-5: Service worker restart drops passkey approval callbacks192193**File:** `src/background/passkey.ts`194195The 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.196197This 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.198199---200201## 5. Functional Gaps202203### F-1: Sync is architecture-only, not wired204205**Files:** `src/background/messages.ts`, `src/background/sync.ts`206207The message handlers for `TRIGGER_SYNC`, `START_PAIRING`, `CANCEL_PAIRING`, and `ACCEPT_PAIRING` all return `{ ok: true }` without calling anything:208209```ts210case "TRIGGER_SYNC":211  return { ok: true };   // no-op212case "START_PAIRING":213  return { ok: true };   // no-op214```215216`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.217218The sync architecture is sound. Wiring it up is the remaining work.219220---221222### F-2: Save detection misses SPA form submissions223224**File:** `src/content/save.ts`225226Save 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.227228Detecting 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.229230---231232### F-3: Non-Tailscale Wi-Fi networks cannot be trusted233234**File:** `src/background/sync-network.ts`, `getCurrentNetwork()`235236`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.237238The `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.239240**Impact:** Without Tailscale, sync trust management is non-functional. This should be documented prominently in the sync UI.241242---243244### F-4: getPageDomain() uses naive eTLD parsing in content scripts245246**Files:** `src/content/detect.ts` line 125, `src/content/fill.ts`247248Both content script files compute domain with:249```ts250const parts = location.hostname.split(".");251return parts.slice(-2).join(".");252```253254This 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.255256`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.257258The risk in practice is low (most affected domains are regional government/education sites) but it's a correctness bug.259260---261262## 6. Dead Code263264| 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 |270271---272273## 7. Architecture Observations274275### Password generator modulo bias276277**File:** `src/shared/generate.ts`278279```ts280const array = crypto.getRandomValues(new Uint32Array(length));281password += chars[array[i] % chars.length];282```283284`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.285286### CBOR is hand-rolled287288**File:** `src/background/passkey-crypto.ts`289290The 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.291292### Algorithm preference: Ed25519 over P-256293294`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.295296### Double decryption on unlock297298**File:** `src/background/vault.ts`299300`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.301302---303304## 8. What the Extension Delivers vs. Website Promises305306| 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 |322323---324325## 9. Fix Priority Order326327For daily driving, address in this order:328329**This session (blockers):**3301. **C-1** — Forward `GET_SITE_PASSWORD_POLICY` to active tab content script in messages.ts3312. **H-4** — Raise `chrome.idle.setDetectionInterval` from 60s to at least 5 minutes3323. **H-2** — Replace `navigator.clipboard.writeText` in content script with background COPY message333334**This week:**3354. **H-3** — Strip `totp` and `username` from the `includeAll` autofill response to content scripts3365. **H-1** — Extend `debouncedReattach` to cover username/email fields, not just password3376. **S-1** — Add `https://` and private-IP validation to canary webhook fetches3387. **S-4** — Confirm `__PEACH_E2E_PASSKEYS__` is not defined in production Vite config3398. Dead code — Remove `escapeHtml`, unused `CHARSET`, duplicate TOTP block340341**When wiring sync:**3429. **F-1** — Connect `TRIGGER_SYNC`, `START_PAIRING`, etc. to actual `sync.ts` functions34310. **F-3** — Surface Tailscale-only limitation in sync UI; grey out non-Tailscale trust option344345---346347## 10. Extension is Safe to Daily Drive After348349- 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 sites353354The 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.