1# Peach Production-Readiness Audit — v1.4.3023**Date:** 2026-04-224**Scope:** Everything except sync (per `peach_audit_instructions.docx` §1.4)5**Baseline:** v1.4.296**Deliverable version:** v1.4.3078---910## 14.1 Executive Summary1112The 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).1314**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.1516Overall posture is strong: crypto primitives, timing-attack resistance, Shadow-DOM isolation, HIBP k-anonymity, canary IP allowlist, and passkey rpId enforcement all check out.1718---1920## 15 Frozen-UI Bug — FIXED2122Applied all four fixes from §15 in this bump:2324- **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.2829Checklist 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 — ✅3536---3738## 14.2 Findings (by priority)3940### P0 — Data loss, lockout, or silent corruption4142#### 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 ```5556#### 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 ```6566#### 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.6970#### 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.7374#### 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.7778### P1 — User-visible wrong behaviour7980#### 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);`.8485#### 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 ```9596#### 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 104 });105 ```106107#### 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 ```121122#### 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 ```130131#### 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.134135#### 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; 142 throw new Error("password is required for login entries");143 }144 ```145146#### 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.150151#### 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.155156#### 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.160161#### 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()`.165166#### 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.170171#### FIND-022 [P1 test gap] `restoreFromSnapshot()` happy path172- **File:** `src/background/vault.test.ts` — add test.173174#### FIND-023 [P1 test gap] `restoreFromSnapshot()` wrong password175- **File:** `src/background/vault.test.ts` — add test: wrong password → no storage writes, returns `false`.176177#### 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.179180### P2 — Code quality / minor UX181182#### 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.185186#### 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.189190#### 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.192193---194195## 14.3 Clean Bills196197Explicit per §14.3:198199- **§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).231232---233234## 14.4 Missing Test Cases (confirmed absent)235236Add to `src/background/vault.test.ts`:237238```ts239test("changePassword happy path — new password unlocks, old rejected", …);240test("changePassword wrong current password → false, disk unchanged", …);241test("changePassword with duress configured — Slot B ciphertext unchanged", …);242test("restoreFromSnapshot with correct password — entries restored", …);243test("restoreFromSnapshot with wrong password — no writes, returns false", …);244test("restoreEntries('replace', []) — refuses to wipe", …); 245```246247Add to `src/crypto/cjkRoundTrip.test.ts`:248249```ts250test("round-trip 0 entries — phrase encodes and decodes back to empty vault", …);251```252253Add to `src/shared/import.test.ts` (or a new `backup.test.ts`):254255```ts256test("importEntriesFromPeachJson with wrong password throws INVALID_EXPORT_PASSWORD", …);257```258259---260261## 14.5 Recommended Patch Order262263Ship these in a single v1.4.30 commit series, in this order:2642651. **§15 fixes** (popup.css + App.tsx) — applied in this bump. Addresses the daily-driver top-priority regression.2662. **FIND-001** — `changePassword` key/disk atomicity. Tight patch, no API change.2673. **FIND-002** + **FIND-019** — vault-layer guards against empty-replace and unconfirmed clear. Defense-in-depth.2684. **FIND-010** — snapshot after `changePassword`. Adds one line inside FIND-001's patch.2695. **FIND-016** — reject password-clear on login entries.2706. **FIND-017** — guard dedupe in decoy mode.2717. **FIND-013** — explicit unlock gate and passkey filter on `GET_ENTRY_FOR_FILL`.2728. **FIND-011** — fix `passwordsMatch` memo.2739. **FIND-012** — content-script sender validation.27410. **FIND-020** — proactive `VAULT_LOCKED` broadcast.27511. **FIND-018** — quota handling.27612. **FIND-014** + **FIND-015** — semantic sender check; MessageType cleanup.27713. **FIND-021** — pass snapshot content explicitly instead of re-reading.27814. **FIND-040** / **FIND-041** — P2 cleanups.27915. **Test gaps** (FIND-003/004/005/022/023/024) — add alongside the relevant fix commits.280281---282283*End of audit deliverable. §15 code fixes are live in this bump; all other findings are documented for developer approval before patching.*