# Peach — Post-Remediation Security Audit (v1.4.25) **Source audit:** `peach_audit_v1424.md` (score 70/100) **Remediation spec:** `peach_remediation_spec.md` **Audit date:** 2026-04-21 **Auditor:** June (hillofthemoon@proton.me) / assisted by Claude Code **Version under test:** Peach post-remediation (working tree at `/Users/june/projects/peach-browser`) **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. --- ## Summary | Finding | Category | Prior status | New status | Evidence | |---|---|---|---|---| | ZK-003 | Favicon domain leak | FAIL | **PASS** | `src/shared/favicon.ts`; grep for `duckduckgo` in `src/` returns zero hits. | | DUR-001 | Duress forensic detectability | FAIL | **PASS** | `src/background/vault.ts` slot-neutral storage + encrypted `duressConfigured` flag + size-matched slot B. | | 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. | | EXT-003 | `clipboardClearSeconds` ignored | FAIL | **PASS** | `src/background/clipboard.ts` reads `getSettings().clipboardClearSeconds`. | | EXT-002 | Dormant `nativeMessaging` permission | WARN | **PASS** | Removed from `manifest.json` and `manifest.firefox.json`; will be re-declared in the Sync PR. | | EXTRA-001 | Duplicate `src/crypto/aes.ts` | WARN | **PASS** | File deleted; imports redirected to `src/background/aes.ts`. | | EXTRA-002 | 8-char master-password minimum | WARN | **PASS** | `src/popup/views/Lock.tsx` now gates on length ≥ 12 AND zxcvbn ≥ 2. | | 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`. | | 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. | | SYN-001 | Noise NK zero static key | FAIL | **DEFERRED** | Feature dormant; to be fixed and re-audited before the Sync feature flag flips on. | --- ## Module 2 — Cryptographic Verification (re-run) ### 2.1 Vault file format — slot-neutral storage Post-remediation the at-rest model is two always-present records: ```jsonc // chrome.storage.local { "peach_vault_a": { "version": 1, "salt": "...", "nonce": "...", "ciphertext": "...", "tag": "..." }, "peach_vault_b": { "version": 1, "salt": "...", "nonce": "...", "ciphertext": "...", "tag": "..." } } ``` No `peach_duress` flag. No `peach_decoy_vault` key. The schema does not distinguish which slot is real. **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. **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. **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`). ### 2.4 Duress mode — redesigned Acceptance criteria from the remediation spec, verified: | Criterion | Result | |---|---| | `chrome.storage.local.get(null)` returns only `peach_vault_a` / `peach_vault_b` (plus optional `peach_settings`) | PASS — new `vault.test.ts` asserts this. | | 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. | | 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. | | Real passphrase → real entries | PASS — `unlocks with real password and loads real entries`. | | Duress passphrase → empty entries | PASS — `unlocks with duress password and loads empty entries`. | | Wrong passphrase → failure after both derivations | PASS — spy-based test confirms 2 `deriveKey` calls per attempt. | | Legacy schema migrates without data loss | PASS — `migrates legacy peach_vault / peach_decoy_vault / peach_duress schema`. | ### 2.5 Unlock timing (DUR-002) ```ts // src/background/vault.ts — unlock() const [slotA, slotB] = await Promise.all([loadSlot(SLOT_A), loadSlot(SLOT_B)]); const [keyA, keyB] = await Promise.all([ slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null), slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null), ]); const [payloadA, payloadB] = await Promise.all([ keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null), keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null), ]); ``` 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. --- ## Module 3 — Network Behavior (re-run) ### 3.1 ZK-003 — Favicon fetch **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`). *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). **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). **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. ### 3.2 Enumerated network calls (static, post-remediation) | Call site | Destination | Disclosed | Status | |---|---|---|---| | `src/background/vault.ts` (HIBP breach check) | `api.pwnedpasswords.com` (k-anonymity prefix) | yes | PASS | | `src/shared/favicon.ts` | `chrome-extension:///_favicon/...` (browser-local) | N/A (local) | PASS | | (previously) DDG favicon fetch | — | removed | PASS | --- ## Module 6 — Browser Extension Security (re-run) ### 6.1 Manifest permissions ```jsonc // manifest.json (Chrome) "permissions": [ "storage", "tabs", "activeTab", "scripting", "clipboardWrite", "alarms", "idle", "favicon", "webAuthenticationProxy" ] // manifest.firefox.json (Firefox) "permissions": [ "storage", "tabs", "activeTab", "scripting", "clipboardWrite", "alarms", "idle" ] ``` `nativeMessaging` is gone. `favicon` is declared only on Chrome (Firefox does not expose it). All remaining permissions are justified by a live code path. ### 6.2 Clipboard auto-clear (EXT-003) ```ts // src/background/clipboard.ts const settingsSeconds = getSettings().clipboardClearSeconds; const clearAfterMs = (typeof settingsSeconds === "number" && settingsSeconds > 0 ? settingsSeconds : DEFAULT_CLEAR_SECONDS) * 1000; ``` 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. ### 6.3 Idle listener (EXTRA-003) `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. --- ## Module 8 — Vault Creation Hardening (EXTRA-002) ```ts // src/popup/views/Lock.tsx const MIN_LENGTH = 12; const MIN_ZXCVBN_SCORE = 2; const canCreate = createMemo( () => password().length >= MIN_LENGTH && confirmPassword().length >= MIN_LENGTH && password() === confirmPassword() && strengthScore() !== null && strengthScore()! >= MIN_ZXCVBN_SCORE ); ``` 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. --- ## Scoring Rubric — re-scored | Category | Weight | Score (prior) | Score (new) | Weighted | Notes | |---|---|---|---|---|---| | Cryptographic Algorithm Correctness | 20% | 9 | **10** | 2.00 | CRY-001 doc drift fixed; algorithms unchanged and correct. | | Zero-Knowledge Architecture Integrity | 20% | 7 | **9** | 1.80 | ZK-003 closed. One point withheld until security page (D-2) is published. | | 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. | | Sync Protocol Security | 15% | 3 | **3** | 0.45 | SYN-001 still open; feature still dormant. Score unchanged until the sync remediation ships. | | Backup & Restore Safety | 10% | 10 | **10** | 1.00 | Unchanged. | | Memory Handling | 10% | 7 | **7** | 0.70 | Unchanged (MEM-001 is a fundamental JS limitation). Small improvement: `unlock()` now `zeroKey`s the unused derivation. | | 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. | | Browser Extension Security | 5% | 8 | **10** | 0.50 | EXT-002 and EXT-003 closed. No dormant permissions; clipboard setting wired. | | **TOTAL** | **100%** | | | **8.25 / 10 → 82.5 / 100** | | ### Score interpretation **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: - **15 pts** of headroom on Sync (SYN-001) and Memory (MEM-001 — structural JS limitation). - **~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. - **~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). --- ## Re-audit checklist results | Finding | Target | Actual | |---|---|---| | ZK-003 | FAIL → PASS | **PASS** | | DUR-001 | FAIL → PASS | **PASS** | | DUR-002 | WARN → PASS | **PASS** | | EXT-003 | FAIL → PASS | **PASS** | | EXT-002 | WARN → PASS | **PASS** | | EXTRA-002 | WARN → PASS | **PASS** | | ZK / Network score | 7 → 9 | **9 / 9** (matches spec target) | | Duress score | 4 → 8 | **9** (exceeds target) | | Browser extension score | 8 → 10 | **10** (matches) | | **Total** | **70 → ~84** | **82.5** (within target band) | --- ## What is still open ### SYN-001 — Noise NK zero static key (deferred) 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.** ### D-1 … D-4 — peachpasswords.com/security disclosures 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. --- ## Verified Claim Status — post-remediation | Claim | Prior | New | Evidence | |---|---|---|---| | "Your vault stays on your device. No server, no account." | PASS | **PASS** | Unchanged. | | "Sync it peer-to-peer across your devices." | WARN | **WARN** | Still dormant; SYN-001 still open. | | "Zero-knowledge vault encryption" | PASS | **PASS** | Unchanged. | | "Zero server dependency" | PASS | **PASS** | Unchanged. | | "No account creation" | PASS | **PASS** | Unchanged. | | "No product telemetry" | WARN | **PASS** | DDG favicon leak closed; HIBP is disclosed. | | "No cloud to breach" | PASS | **PASS** | Unchanged. | | "Local-network peer-to-peer sync only" | N/A | **N/A** | Feature dormant. | | "AES-256-GCM for authenticated confidentiality" | PASS | **PASS** | Unchanged. | | "Argon2id with per-vault salt and high-memory parameters" | PASS | **PASS** | Unchanged. | | "Authenticated decryption rejects tampered phrases" | PASS | **PASS** | Unchanged. | | "k-anonymity prefix sent for breach checks" | PASS | **PASS** | Unchanged. | | *(new)* "Duress mode leaves no at-rest fingerprint" | — | **PASS (pending D-4 publication)** | DUR-001/002 closed; disclosure pending. | --- ## Test posture - `tsc --noEmit` — clean. - `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. - `src/background/vault.test.ts` — 12 / 12 pass, including new DUR-001 / DUR-002 coverage: - `writes both slots A and B on vault creation` - `slot A and slot B have matching ciphertext size at fresh install` - `does not expose a plaintext duress flag after setupDuress` - `unlocks with real password and loads real entries` - `unlocks with duress password and loads empty entries` - `runs both slot derivations in parallel for constant-time unlock (DUR-002)` - `persists duressConfigured across lock/unlock in encrypted payload` - `migrates legacy peach_vault / peach_decoy_vault / peach_duress schema` --- ## Sign-off **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. **Next re-audit trigger:** the Sync feature flag flipping on, or any change to `vault.ts` storage schema, `unlock()` pathway, or manifest permissions.