peach_audit_v1425.md

15.4 KB · raw

rendered · source

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:


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


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.