peach_audit_v1425.md
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 ([email protected]) / 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:
// 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)
// 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://<id>/_favicon/... (browser-local) |
N/A (local) | PASS |
| (previously) DDG favicon fetch | — | removed | PASS |
Module 6 — Browser Extension Security (re-run)
6.1 Manifest permissions
// 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)
// 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)
// 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 zeroKeys 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 unmodifiedHEADand 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 creationslot A and slot B have matching ciphertext size at fresh installdoes not expose a plaintext duress flag after setupDuressunlocks with real password and loads real entriesunlocks with duress password and loads empty entriesruns both slot derivations in parallel for constant-time unlock (DUR-002)persists duressConfigured across lock/unlock in encrypted payloadmigrates 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.