1# Peach — Post-Remediation Security Audit (v1.4.25)23**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.910---1112## Summary1314| 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. |2627---2829## Module 2 — Cryptographic Verification (re-run)3031### 2.1 Vault file format — slot-neutral storage3233Post-remediation the at-rest model is two always-present records:3435```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```4243No `peach_duress` flag. No `peach_decoy_vault` key. The schema does not distinguish which slot is real.4445**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.4647**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.4849**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`).5051### 2.4 Duress mode — redesigned5253Acceptance criteria from the remediation spec, verified:5455| 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`. |6465### 2.5 Unlock timing (DUR-002)6667```ts6869const [slotA, slotB] = await Promise.all([loadSlot(SLOT_A), loadSlot(SLOT_B)]);70const [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]);74const [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```7980Both 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.8182---8384## Module 3 — Network Behavior (re-run)8586### 3.1 ZK-003 — Favicon fetch8788**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`).8990*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).9192**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).9394**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.9596### 3.2 Enumerated network calls (static, post-remediation)9798| 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 |103104---105106## Module 6 — Browser Extension Security (re-run)107108### 6.1 Manifest permissions109110```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```123124`nativeMessaging` is gone. `favicon` is declared only on Chrome (Firefox does not expose it). All remaining permissions are justified by a live code path.125126### 6.2 Clipboard auto-clear (EXT-003)127128```ts129130const settingsSeconds = getSettings().clipboardClearSeconds;131const clearAfterMs =132 (typeof settingsSeconds === "number" && settingsSeconds > 0133 ? settingsSeconds134 : DEFAULT_CLEAR_SECONDS) * 1000;135```136137The 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.138139### 6.3 Idle listener (EXTRA-003)140141`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.142143---144145## Module 8 — Vault Creation Hardening (EXTRA-002)146147```ts148149const MIN_LENGTH = 12;150const MIN_ZXCVBN_SCORE = 2;151152const 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```161162The 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.163164---165166## Scoring Rubric — re-scored167168| 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** | |179180### Score interpretation181182**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:183184- **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).187188---189190## Re-audit checklist results191192| 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) |204205---206207## What is still open208209### SYN-001 — Noise NK zero static key (deferred)210211The 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.**212213### D-1 … D-4 — peachpasswords.com/security disclosures214215These 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.216217---218219## Verified Claim Status — post-remediation220221| 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. |236237---238239## Test posture240241- `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`252253---254255## Sign-off256257**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.258259**Next re-audit trigger:** the Sync feature flag flipping on, or any change to `vault.ts` storage schema, `unlock()` pathway, or manifest permissions.