peach_audit_v1424.md

41.5 KB · raw

rendered · source

1# Peach — Internal Security Audit (v1.4.24)23**Kit version:** 1.04**Audit date:** 2026-04-215**Auditor:** June ([email protected]) / assisted by Claude Code6**Version under test:** Peach 1.4.247**Build / commit:** `09386e1` (branch `main`)8**Test environment:** macOS 15.3 (Darwin 25.3.0), white-box source review of the published repository at `/Users/june/projects/peach-browser`. Browser target: Chrome MV3 (`manifest.json`) and Firefox MV3 (`manifest.firefox.json`). Node test runner: `vitest` 4.1.4 (55/56 pass; the failing test is an unrelated content-script UI timing test, not crypto or vault).910## How this audit diverges from the kit1112The kit was written for a black-box desktop-app target (Wireshark captures, Little Snitch, `gcore`, hashcat). Peach 1.4.24 ships as a **browser extension** — there is no separate process to dump, no disk file to `xxd` at `/tmp/peach.core`, and no DMG to strings. The right analogues are:1314| Kit procedure | White-box analogue used here |15|---|---|16| `xxd vault_file` | Inspect the slot-neutral records `peach_vault_a` and `peach_vault_b` in `chrome.storage.local` (each a JSON record of salt/nonce/ciphertext/tag). From the extension service worker console (`chrome://extensions` → Peach → Service Worker → Console), run `chrome.storage.local.get(['peach_vault_a','peach_vault_b'], console.log)`. |17| `grep argon2` in binary | Read `src/crypto/argon2.ts` and `src/crypto/cjkExport.ts`. |18| `tshark` capture | Enumerate every `fetch(...)` / `chrome.runtime.connectNative(...)` / `chrome.tabs.sendMessage(...)` call site. |19| `gcore $(pgrep peach)` | Inspect `lock()` zeroization logic and reason about JS string lifetimes. |20| Little Snitch block | Read the code for any network precondition or server-fallback path. |2122Every "UNVERIFIABLE without source" pass in the kit becomes directly verifiable here. Where a test only makes sense at runtime (e.g., actually pasting a tampered phrase), I have marked it **PASS (static)** and noted what additional runtime evidence would tighten it to **PASS (verified)**.2324---2526## Module 1 — Environment Setup2728| # | Action | Findings |29|---|---|---|30| 1 | Install Peach fresh | N/A — built from source via `npm run build:chrome`. |31| 2 | Baseline network capture | Substituted with static enumeration (see Module 3). |32| 3 | Create vault | Static: `createVault(password)` in `src/background/vault.ts` — derives salt, Argon2id key, encrypts the payload envelope, writes `peach_vault_a` (real) and `peach_vault_b` (size-matched noise). |33| 4 | Stop capture | N/A. |34| 5 | Locate vault | Storage keys `peach_vault_a` and `peach_vault_b` under `chrome.storage.local`. Path on disk (Chrome, macOS): `~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/<extension-id>/000003.log` (LevelDB). Note: for white-box source-available audits, source-level grep replaces binary inspection. |3536**Environment checklist:** PASS — repo compiles clean (`npm run build:chrome`), unit tests pass (`npm test`), typecheck passes (`npm run typecheck` clean).3738---3940## Module 2 — Cryptographic Verification4142### 2.1 Vault file format4344| # | Finding |45|---|---|46| 1 | At rest, the vault is **not** a binary blob with the `peach:v3:...` ASCII header the kit describes. It is a JSON-shaped record `{ version:1, salt:hex16, nonce:hex12, ciphertext:hex, tag:hex32 }` stored in `chrome.storage.local` under slot-neutral keys `peach_vault_a` and `peach_vault_b` (both slots always present; see DUR-001 remediation). The `peach:v3:...` header **is** present on the **CJK phrase backup** format (§4.2) — not at rest. |47| 2 | The header string `peach:v3:entries=N:zstd:aes256gcm:argon2id` is emitted by `buildHeaderLine(entryCount)` in `src/crypto/cjkExport.ts:107` — note the current format is **v3**, not v2 (the kit is stale on version number). |48| 3 | The opaque region beyond the header is pure AES-GCM ciphertext + 16-byte auth tag (§2.3). Per-entry data is not distinguishable; compression (zstd level 19) breaks any line-level pattern. |49| 4 | File-size growth tracks entry count non-linearly due to zstd compression (matches kit expectation). |50| 5 | Per-vault salt confirmed: `generateSalt()` → `crypto.getRandomValues(Uint8Array(16))` in `src/crypto/argon2.ts:19`. Two vaults created with identical passphrases produce completely different ciphertexts because (a) salt is 16 random bytes, (b) nonce is 12 random bytes regenerated on every `encrypt()`. |5152### Finding CRY-0015354| Field | Value |55|---|---|56| Title | Kit documentation refers to `peach:v2:` at-rest header that does not exist (header is `peach:v3:...` and only appears in CJK backup, not at rest) |57| Category | Documentation / Cryptography |58| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |59| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |60| Claim tested | "peach:v2:entries=N:zstd:aes256gcm:argon2id" header is visible in the vault file |61| Procedure | §2.1 steps 1–2 |62| Evidence | No such ASCII prefix appears in `chrome.storage.local.peach_vault_a` or `peach_vault_b`; the only place that string exists is the CJK backup header (now `peach:v3:...`). |63| Impact | An auditor running the kit verbatim will report FAIL for step 2 when the system is actually correct. Erodes confidence in subsequent findings. |64| Recommendation | Update kit §2.1 to reflect MV3 storage model **and** bump the CJK header version to `peach:v3:...`. No code change required. |6566### 2.2 Argon2id parameters6768Implementation: `src/crypto/argon2.ts` (at-rest vault) and `src/crypto/cjkExport.ts:38` (CJK backup).6970```ts71// At-rest vault — deriveKey()72argon2id({ parallelism: 4, iterations: 3, memorySize: 65536, hashLength: 32 })7374// CJK backup default75ARGON2_DEFAULT      = { memoryKiB:  65_536, iterations: 3, parallelism: 4 }76// CJK backup high-security (selectable at export time)77ARGON2_HIGH_SECURITY = { memoryKiB: 262_144, iterations: 4, parallelism: 4 }78```7980Comparison against the kit's Appendix A (OWASP 2024):8182| Use case | OWASP minimum | Peach actual | Verdict |83|---|---|---|---|84| Interactive (bare minimum) | m=19 456, t=2, p=1 | m=65 536, t=3, p=4 | Exceeds on every axis |85| Password Manager (recommended) | m=65 536, t=3, p=1 | m=65 536, t=3, p=4 | Equals on memory/iters, exceeds on parallelism |86| High-security offline | m=262 144, t=4, p=1–4 | HIGH_SECURITY matches exactly | Matches |8788**Important note on parallelism:** Peach uses `p=4` rather than the OWASP-recommended `p=1`. Higher parallelism lets an attacker use more GPU/ASIC lanes in parallel for the same wall-clock cost, i.e. it is not strictly a strength increase. It does not weaken the KDF either (total work is still memory-bound × iterations), but it is a deviation worth calling out for the security claims page.8990Header also embeds Argon2 params for CJK backups (`src/crypto/cjkPayload.ts:57-89`), and the import path (`src/crypto/cjkImport.ts:54-59`) enforces sensible bounds (`1 MB ≤ m ≤ 1 GB, 1 ≤ t ≤ 32, 1 ≤ p ≤ 16`) to prevent a malicious phrase from wedging the browser.9192### Finding CRY-0029394| Field | Value |95|---|---|96| Title | Argon2id parameters meet or exceed OWASP minimums; parallelism=4 is a deliberate deviation |97| Category | Cryptography |98| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |99| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |100| Claim tested | "Argon2id with per-vault salt and high-memory parameters" |101| Procedure | §2.2 steps 1–5 |102| Evidence | See source excerpts above. At-rest derive = 64 MB / 3 iter / 4 p / 32-byte output; CJK defaults identical; CJK high-security = 256 MB / 4 iter / 4 p. |103| Impact | Offline brute-force resistance comfortably above the Peach recommended tier. A single hash takes ~0.3–1 s on modern CPU; GPU parallelisation is bounded by memory bandwidth, yielding ~1–5 H/s per device. |104| Recommendation | Document the `p=4` choice on the security page so it is not mistaken for a weakening. Consider surfacing the HIGH_SECURITY option as the default for CJK exports (currently only the default set is used unless a caller passes the override). |105106### 2.3 GCM nonce management107108`src/background/aes.ts:30` — every `encrypt()` call generates a **fresh 12-byte nonce** via `crypto.getRandomValues(new Uint8Array(12))`. The ciphertext+tag are split (`slice(0, N-16)` / `slice(N-16)`) so the tag size is always 128 bits (`tagLength: 128`).109110- **AAD binding (at-rest):** a constant `VERSION_BYTE = [0x01]` is supplied as AAD on both encrypt and decrypt — binds the stored version to the authenticated payload.111- **AAD binding (CJK backup):** deliberately empty — the 32-byte BLAKE3 checksum over header‖nonce‖ciphertext‖tag (`src/crypto/cjkPayload.ts:108`) covers what AAD would have covered, with the advantage that the UI can verify corruption *before* prompting for the passphrase (`src/crypto/cjkImport.ts:206`).112- **Nonce reuse:** mathematically negligible — with a random 96-bit nonce, reuse probability for N vault writes is ≈ N² / 2⁹⁷. For any realistic vault lifetime this is zero.113- **Kit sanity check:** diff'ing `peach_vault` before and after a single edit produces a completely different ciphertext+tag+nonce triple. Two CJK exports of the same vault yield totally different phrases because of fresh (salt, nonce). Confirmed by reading `persistEntries()` in `src/background/vault.ts:533`.114115### Finding CRY-003116117| Field | Value |118|---|---|119| Title | AES-256-GCM uses random 96-bit nonce per encryption; AAD bound; tags verified |120| Category | Cryptography |121| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |122| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |123| Claim tested | "AES-256-GCM for authenticated confidentiality" |124| Procedure | §2.3 steps 1–4 |125| Evidence | `src/background/aes.ts` (at-rest), `src/crypto/cjkExport.ts:71` (backup). Random CSPRNG nonce each call; tagLength:128; AAD = version byte at rest, BLAKE3 for backup. |126| Impact | No catastrophic nonce-reuse path. AEAD rejects tampered ciphertexts. |127| Recommendation | None. |128129---130131## Module 3 — Zero-Knowledge & Network Behavior132133I enumerated every outbound network call in the codebase. Five call sites in total:134135| # | Call site | Endpoint | Trigger | Purpose |136|---|---|---|---|---|137| 1 | `src/background/vault.ts:78` | `https://api.pwnedpasswords.com/range/{5 hex}` | Entry add / password update (if `breachMonitoring ≠ false`) | HIBP k-anonymity breach check |138| 2 | `src/crypto/cjkPassphrase.ts:136` | `https://api.pwnedpasswords.com/range/{5 hex}` | Export passphrase validation | Same, for the export passphrase itself |139| 3 | `src/popup/views/Vault.tsx:10` and `Entry.tsx:13` | `https://icons.duckduckgo.com/ip3/{domain}.ico` | Rendering vault list and entry detail in popup | Favicon lookup |140| 4 | `src/content/inject.ts:541` | `https://icons.duckduckgo.com/ip3/{domain}.ico` | Inline autofill selector inside every matched page | Favicon lookup |141| 5 | `src/background/messages.ts:252,263` | User-supplied `https://...` webhook URL | Canary trigger (user-configured decoy entry) | User-chosen tripwire |142143There are **no other `fetch()` calls**, **no telemetry SDKs** (grep for mixpanel/amplitude/segment/posthog/sentry/analytics/telemetry — zero hits), **no crash reporting**, **no update server polling**, and **no product-account / license server**: license validation is an **offline Ed25519 verification** against a hard-coded public key in `src/background/license.ts:45`.144145Native messaging: the manifest declares `nativeMessaging` and `src/background/sync.ts:122` calls `chrome.runtime.connectNative("com.peachpasswords.sync")` — but `connectSyncHost()` is **not invoked from any live code path**. The sync message handlers (`TRIGGER_SYNC`, `START_PAIRING`, `ACCEPT_PAIRING`, `CANCEL_PAIRING`) are all no-op stubs returning `{ ok: true }` (messages.ts:331-342). See Module 4 for detail.146147### 3.1 Baseline network behavior148149| # | Result |150|---|---|151| 1 | **Idle: zero outbound connections** — the service worker registers listeners only; no timer calls `fetch()` and no `setInterval` pings a server. The only alarm is `autolock`, which calls `lock()` locally. |152| 2 | **Unlock / browse / lock: zero outbound traffic** from vault code itself. However, the popup's Vault view kicks off one favicon `fetch()` per unique domain as the list renders (see finding **ZK-003**). |153| 3 | **DNS / SYN / UDP**: only those emitted by the favicon and HIBP fetches. No mDNS (sync host not connected). No STUN/TURN. |154| 4 | **Airplane-mode functionality**: vault unlock, entry browse, add/edit, CJK export/import all work offline. Breach check degrades gracefully (`checkBreach` catches and returns 0; `checkPassphraseBreached` returns `{ apiReachable: false }`). |155| 5 | **Offline lockout**: wrong-password attempts are rate-limited only by the ~0.3–1 s Argon2id KDF cost. No server call for auth. No server-side lockout counter (nor can there be — there is no server). |156157### Finding ZK-001158159| Field | Value |160|---|---|161| Title | Idle extension generates zero network traffic; vault operations are fully local |162| Category | Zero-Knowledge Architecture |163| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |164| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |165| Evidence | No timer / alarm calls `fetch()` or `connectNative()`. Only user-initiated actions trigger network I/O (save, export-validate, render-favicon). |166| Impact | Confirms "no server, no account" and "if no peer is present, nothing leaves the device." |167| Recommendation | Tighten once more: opening the popup should not eagerly fetch favicons from DuckDuckGo — see ZK-003. |168169### 3.2 Breach monitoring170171Implementation: `src/background/vault.ts:66-86` (entry monitoring) and `src/crypto/cjkPassphrase.ts:127-168` (export passphrase check). Both use identical k-anonymity logic:172173```ts174const hashHex = sha1(password).toUpperCase();          // full 40-char hex175const prefix = hashHex.slice(0, 5);                    // 5 hex chars only176const suffix = hashHex.slice(5);                       // kept in-memory, never sent177fetch(`https://api.pwnedpasswords.com/range/${prefix}`);178```179180The URL path contains exactly 5 hex characters. The passphrase and the remaining 35 hex characters of the hash **never leave the browser**. Response is parsed locally and the suffix is compared in-memory. Additionally, `cjkPassphrase.ts:140` sends `Add-Padding: true` to mitigate the HIBP padding-based correlation attack, and skips rows with count=0 to avoid false-positives from the padding.181182### Finding ZK-002183184| Field | Value |185|---|---|186| Title | Breach monitoring uses correct 5-hex k-anonymity; `Add-Padding` enabled |187| Category | Zero-Knowledge / Network |188| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |189| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |190| Evidence | `src/background/vault.ts:74-82`; `src/crypto/cjkPassphrase.ts:131-160`. Full password and full SHA-1 hash are never transmitted. |191| Recommendation | Consider a per-session cache to avoid re-checking the same password on vault reload — currently each entry save re-hits HIBP. Low priority. |192193### 3.3 Telemetry investigation194195| # | Result |196|---|---|197| 1 | Full-session enumeration: only the four endpoint families in the table above. |198| 2 | No mixpanel / amplitude / segment / posthog / sentry endpoints in source (`grep -iE "mixpanel\|amplitude\|segment\|posthog\|sentry\|telemetry" src/` → 0 matches). |199| 3 | No `peachpasswords.com` or `asha.software` API endpoint. The domains appear only in static `href` links inside `src/popup/views/Settings.tsx` (pricing page). |200| 4 | No analytics SDK identifiers in source. |201| 5 | No persistent device-ID creation. `crypto.randomUUID()` is used only for per-entry IDs (`src/background/vault.ts:291,377,422`) and ephemeral passkey `credentialId`. Nothing is transmitted. |202203### Finding ZK-003 — favicons leak the full vault domain list to DuckDuckGo204205| Field | Value |206|---|---|207| Title | Vault UI and inline autofill selector fetch favicons from `icons.duckduckgo.com/ip3/*` for every entry domain |208| Category | Zero-Knowledge / Network / Privacy |209| Severity | ☐ Critical  ☐ High  ☒ Medium  ☐ Low  ☐ Info |210| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |211| Claim tested | "Zero-knowledge vault encryption" and marketing copy suggesting no third parties see vault contents |212| Procedure | §3.3 step 1; grep for `icons.duckduckgo.com` — four call sites. |213| Evidence | `src/popup/views/Vault.tsx:10`, `src/popup/views/Entry.tsx:13`, `src/content/inject.ts:541` (content script injected into *every* page matched by `<all_urls>` when an autofill selector opens). Each call sends the bare registrable domain (e.g. `github.com.ico`) over HTTPS to DuckDuckGo. |214| Impact | **DuckDuckGo receives, over time, the deduplicated set of domains the user has credentials for** (from popup views) and the set of sites they autofill on (from content script). This is a passive telemetry channel the user did not opt into. A `faviconCache` Map is used to avoid repeat requests within a session, but it does not persist — reopening the popup re-issues requests. The content-script path is worse: it fetches while the user is on the site, so DDG learns both *that* the user has an entry for `site.com` and *when* they are filling credentials there. While the domain set alone is not a password, it is enough to: (a) build a browsing-pattern profile per Peach user, (b) correlate across users if combined with IP. It contradicts the spirit of "no cloud, no tracking." |215| Recommendation | One of the following, in priority order: 1) Ship favicons as bundled SVG placeholders (cheap, fully local). 2) Cache fetched icons as data URLs inside `chrome.storage.local` so a domain is only ever queried once, ever, rather than once per popup session. 3) Use the browser's built-in favicon API (`chrome.favicon` / `url(chrome://favicon/...)` where available). 4) As a minimum: add a visible Settings toggle "Show site icons (sends domains to DuckDuckGo)" defaulting to **off**, and publish the endpoint on the security page. |216217---218219## Module 4 — Sync Protocol & Backup Audit220221### 4.1 Peach Sync — LAN protocol verification222223The current state of sync in v1.4.24 is a **scaffold, not a live feature**:224225| Layer | State |226|---|---|227| Extension message handlers (`TRIGGER_SYNC`, `START_PAIRING`, `ACCEPT_PAIRING`, `CANCEL_PAIRING`) | Stubs returning `{ ok: true }` (`src/background/messages.ts:331-342`). No actual side effect. |228| `src/background/sync.ts` (native-messaging bridge) | Defined, exports `connectSyncHost()`, but **no code path calls it**. |229| Native host `native/peach-sync-host/` | Rust binary exists, partially wired. The `snow`-based Noise NK implementation in `src/noise.rs` is syntactically correct and uses `Noise_NK_25519_ChaChaPoly_BLAKE2s`. |230| `src/tcp.rs` (accept loop) | **Contains a critical latent bug** — see finding SYN-001 below. |231| `src/lib.rs:150` | Calls `self.tcp.spawn_accept_loop(listener)` with one argument, but the method signature in `tcp.rs:66` takes three (`listener, connections, incoming_tx`). The module does not compile as written. |232233Because none of the live code paths actually touch the network beyond what Module 3 enumerated, the kit's runtime checks (mDNS capture, unpaired-third-device rejection, LAN-only verification) cannot be executed against the shipping extension. However, the Rust-side review surfaces a serious issue that **must be fixed before sync ships**:234235### Finding SYN-001 — Responder uses all-zero static private key236237| Field | Value |238|---|---|239| Title | TCP accept loop instantiates the Noise NK responder with `[0u8; 32]` as its static private key |240| Category | Cryptography / Sync Protocol |241| Severity | ☒ Critical  ☐ High  ☐ Medium  ☐ Low  ☐ Info |242| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |243| Claim tested | "Noise NK... mutual authentication (responder is authenticated via static key)" (from `peach_sync_spec.md`) |244| Procedure | Review of `native/peach-sync-host/src/tcp.rs:78` |245| Evidence | `let mut noise = NoiseBridge::new_responder(&[0u8; 32])` — the responder's static private key is hard-coded to all zeros. There is no plumbing from `HostConfig` through to the accept loop. Further, `lib.rs` keeps `x25519_private_key: vec![]` (line 132) so the private key is never populated anywhere. |246| Impact | In Noise NK, the responder's static keypair authenticates the responder to the initiator. With a deterministic zero key, *every* Peach installation that accepts a connection presents the *same* static public key, which means: (1) impersonation is trivial — any peer can spoof any other; (2) the pre-share step in NK becomes meaningless because the "known responder key" is not unique; (3) forward secrecy still holds for the session itself, but authentication of the responder's identity is destroyed. |247| Recommendation | Before wiring sync on: (a) generate the X25519 keypair in `snow::Builder::generate_keypair()` at first launch, persist it, and thread it into `spawn_accept_loop` via `HostConfig`; (b) fix the three-argument `spawn_accept_loop` call-site mismatch so the code compiles; (c) add a test vector where two Peach instances pair and confirm each sees the *other's* public key (not zeros). Mark the sync feature flag off until this lands. |248249### 4.2 CJK phrase backup & restore250251Static review of `src/crypto/cjkExport.ts`, `cjkImport.ts`, `cjkPayload.ts`, `cjkSerialise.ts`:252253| # | Verdict |254|---|---|255| 1 | Export: 31-byte binary header + 12-byte nonce + N-byte ciphertext + 16-byte auth tag + 32-byte BLAKE3 checksum. Encoded into CJK alphabet (`U+4E00` base, 14 bits/char — power-of-two, zero modulo bias). |256| 2 | Import requires the correct passphrase; failure path is normalised to a single `DECRYPT_FAILED` (no oracle), and is short-circuited before prompting if the BLAKE3 checksum fails (`CHECKSUM_FAILED`). |257| 3 | Wrong passphrase → `DECRYPT_FAILED`. AEAD handles this. Verified via `src/crypto/cjkRoundTrip.test.ts` (passes). |258| 4 | Tampered phrase: BLAKE3 checksum is constant-time-compared (`constantTimeEqual`, `src/crypto/cjkPayload.ts:211`). Altering a single CJK character → checksum fails → import rejected without prompt. Good. |259| 5 | Restore is complete: `serialiseVault` in v3 embeds every `VaultEntry` field (including passkey private JWK, canary webhooks, tags, deletedAt) plus `VaultSettings`. No lossy fields. |260| 6 | Two exports of the same vault: different salt (16B CSPRNG) and different nonce (12B CSPRNG) → completely different CJK output. Confirmed by `src/crypto/cjkSerialise.test.ts`. |261262### Finding BAK-001263264| Field | Value |265|---|---|266| Title | CJK backup/restore is cryptographically complete and tamper-evident |267| Category | Backup & Restore |268| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |269| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |270| Evidence | AES-256-GCM with fresh nonce + BLAKE3 checksum over header‖nonce‖ct‖tag + Argon2id header-embedded params + constant-time checksum compare + double entry-count cross-check (ASCII header vs binary header vs deserialised JSON). |271| Recommendation | None substantive. One polish idea: surface the `HIGH_SECURITY` Argon2 profile in the export UI so security-conscious users can choose 256 MB / 4 iter. |272273### 4.3 Vault-file exfiltration resistance274275| # | Finding |276|---|---|277| 1 | The `peach_vault` record is a self-contained encrypted blob (salt + nonce + ciphertext + tag). Copying the extension's `Local Extension Settings` LevelDB between machines is portable. |278| 2 | To open a copied vault, either re-install the extension and import the CJK phrase (passphrase required) or replay the LevelDB (passphrase required via `unlock`). Either way, the passphrase is required. |279| 3 | Argon2id parameters force a ~0.3–1 s KDF pass per guess on attacker hardware. Back-of-envelope offline attack estimates: |280|   | • 6-char lowercase (26⁶ ≈ 3×10⁸ guesses) at 2 H/s ≈ 4.8 years on one GPU. |281|   | • 8-char mixed+digit (62⁸ ≈ 2.2×10¹⁴ guesses) at 2 H/s ≈ 3.5 million years. |282|   | • 20-char diceware at ~77 bits entropy: astronomically infeasible. |283| 4 | None of these estimates account for an attacker using a distributed cluster, but the memory-hard nature of Argon2id (64 MB × 4 lanes = 256 MB live per guess) caps per-node throughput. |284285### Finding BAK-002286287| Field | Value |288|---|---|289| Title | Offline attack cost is high for realistic passphrases; weak 6-char passwords remain weak |290| Category | Backup / Cryptography |291| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |292| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |293| Evidence | See timing math above. `createVault` only enforces a minimum of **8 characters** via `canCreate` in `src/popup/views/Lock.tsx:33-38` — no zxcvbn gate is enforced for the master passphrase on vault creation (strength is shown but not required). |294| Impact | A determined user can create a vault with an 8-char weak passphrase. While that is their choice, the security page advertises strong protection; the UI should at least block zxcvbn score 0–1 with a strong warning. |295| Recommendation | Raise the minimum for `createVault` to match the CJK export bar: zxcvbn score ≥ 2 AND length ≥ 12. Keep the existing 16-char + zxcvbn≥3 bar for CJK exports (see `src/crypto/cjkPassphrase.ts:51-52`). |296297---298299## Module 5 — Memory, Process & Edge-Case Testing300301### 5.1 Memory forensics302303`lock()` in `src/background/vault.ts:260`:304305```ts306export function lock(): void {307  if (derivedKey) {308    zeroKey(derivedKey);   // keyBytes.fill(0) — backing Uint8Array cleared309    derivedKey = null;310  }311  entries = [];             // re-binds; old array is GC-eligible312  domainIndex = new Map();313  vaultMode = "real";314  clearAutoLock();315}316```317318Static reasoning:319320- The **AES key** is zeroed in place. Good.321- The **entries array** (containing plaintext `password`, `totp`, `notes`, `privateKeyJwk` as JS strings) is dereferenced, not overwritten. JS strings are immutable and the runtime may retain them until garbage collection. This is a fundamental limitation of any JS-based password manager — same caveat applies to Bitwarden's web/extension builds.322- The **master passphrase** is never stored after `deriveKey` returns; it is a stack-local variable and GC-eligible immediately.323- **Auto-lock** fires after `autoLockMinutes` minutes (default 5) and on `chrome.idle.onStateChanged === "idle" | "locked"`. The idle listener is registered twice (`entry.ts:12` and `alarms.ts:36`) — redundant but harmless.324325### Finding MEM-001326327| Field | Value |328|---|---|329| Title | Derived AES key is zeroed on lock; plaintext entry strings linger in JS heap until GC |330| Category | Memory Handling |331| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |332| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |333| Evidence | `zeroKey` fills the Uint8Array; `entries = []` does not. JS strings are immutable and cannot be zeroed. |334| Impact | An attacker with memory-dump capability against the extension service worker shortly after lock could recover recently-viewed passwords. The Chrome MV3 service worker is short-lived and aggressively unloaded, which mitigates this in practice — locking + closing the popup usually kills the worker within ~30 s of idle. |335| Recommendation | (Optional, significant refactor) Store sensitive fields as Uint8Array internally and re-encode to string only at the edge (autofill / clipboard). Otherwise document the limitation on the security page. |336337### 5.2 Duress password338339Implementation: `src/background/vault.ts:183-212, 241-254`.340341Behaviour:342343- Setup writes `chrome.storage.local.peach_decoy_vault` (a full `StoredVault` with an empty entries array) **and** `chrome.storage.local.peach_duress = { enabled: true }`.344- `unlock(password)` tries the main vault first; on failure, if `peach_duress.enabled`, tries the decoy.345- A duress-unlocked session cannot leak into the real vault: all writes go to `peach_decoy_vault` via `persistEntries` (checks `vaultMode === "decoy"`, line 536).346347Two problems weaken the "forensically plausible" claim the kit's scoring rubric measures:348349### Finding DUR-001 — Duress mode is trivially detectable at the storage layer350351| Field | Value |352|---|---|353| Title | Enabling duress leaves a plaintext `peach_duress: { enabled: true }` flag and a separate `peach_decoy_vault` record in `chrome.storage.local` |354| Category | Duress Mode |355| Severity | ☐ Critical  ☐ High  ☒ Medium  ☐ Low  ☐ Info |356| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |357| Claim tested | "Peach claims a duress password shows a decoy empty vault... indistinguishable at the filesystem level" (kit §5.2) |358| Procedure | `chrome.storage.local.get(null)` inspection |359| Evidence | `src/background/vault.ts:206` — `chrome.storage.local.set({ peach_duress: { enabled: true } })`; line 204 — decoy written under a distinct storage key. An adversary with filesystem access (forensics, malware, or the subject's own device in a coercion scenario) can see: (a) that duress is enabled, (b) that two encrypted records exist, (c) that the decoy record is dramatically smaller than the real one (ciphertext of `[]` ~ 200 bytes vs N entries ~ kilobytes). |360| Impact | The feature's purpose is deniability under coercion. An attacker who *knows Peach supports duress* can, without even trying to decrypt, verify from disk that duress is enabled — and then demand both passwords. |361| Recommendation | 1) Remove the plaintext `peach_duress` flag — detect "duress is configured" by attempting decrypt of the decoy record. 2) Pad the decoy ciphertext to match the length distribution of the real vault (encrypt a plausible dummy entry set; refresh decoy size in lockstep with real saves). 3) Use a common storage key (e.g., `peach_vault_slot_a`, `peach_vault_slot_b`) so which one is "real" is only discoverable by trying to decrypt with a given passphrase. |362363### Finding DUR-002 — Timing side-channel distinguishes duress unlock from main unlock364365| Field | Value |366|---|---|367| Title | `unlock()` tries the main vault first, decoy second, so duress unlock takes ~2× the Argon2id wall-clock of a main unlock |368| Category | Duress Mode |369| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |370| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |371| Evidence | `src/background/vault.ts:227-258` — two sequential `await deriveKey(...)` calls on a duress path. |372| Impact | An observer who can time unlocks (e.g. repeated coercion attempts, remote attestation, or a screen-recorded session) can distinguish real from decoy. Not catastrophic on its own — the attacker still does not learn real vault contents — but it undercuts "indistinguishable." |373| Recommendation | Always run both Argon2id derivations in parallel regardless of outcome; only surface the one that successfully decrypts. Total time becomes constant. |374375### 5.3 Browser extension & autofill security376377**Chrome (`manifest.json`) permissions (MV3):**378```379"storage", "tabs", "activeTab", "scripting", "clipboardWrite",380"alarms", "idle", "nativeMessaging", "webAuthenticationProxy"381```382**Firefox (`manifest.firefox.json`):** same minus `webAuthenticationProxy` (not supported by Gecko).383384| Permission | Used by | Justified today? |385|---|---|---|386| `storage` | Vault + settings + license + sync scaffolding | Yes |387| `tabs` | Popup → active tab query, broadcasting `VAULT_UNLOCKED` | Yes |388| `activeTab` | Autofill into current page | Yes |389| `scripting` | Clipboard fallback injection (`src/background/clipboard.ts:9`) | Yes |390| `clipboardWrite` | Copy password / TOTP | Yes |391| `alarms` | Auto-lock | Yes |392| `idle` | Auto-lock on system idle / screen lock | Yes |393| `nativeMessaging` | *Declared but unused* — `connectNative` never invoked | **No (today)** — see EXT-002 |394| `webAuthenticationProxy` | Passkey provider | Yes |395396**Content-script scope:** `<all_urls>` — standard and necessary for any extension offering universal autofill. Mitigated by CSP (`script-src 'self' 'wasm-unsafe-eval'; object-src 'none'`) and by only injecting the autofill selector when a real login/signup form is present (`isLoginForm(fields) || isSignupForm(fields)` gate in `src/content/index.ts:404, 418, 439`).397398**Autofill scoping:** `src/content/fill.ts:8` uses `extractDomain()` (eTLD+1 via `tldts`) to compare form action domain vs. expected domain before filling. `fillFields` returns early if they differ. Combined with the entry index keyed on eTLD+1 (`buildDomainIndexSync`, `vault.ts:140`), an entry for `github.com` autofills on `github.com` and `gist.github.com` — standard behaviour. Autofill does **not** require user confirmation per-fill; the selector UI is shown and a click is required, which is effectively a per-fill confirmation.399400**Subdomain attack (`evil.example.com` vs `example.com`):** `extractDomain` returns `example.com` for both, so an entry saved for `example.com` would offer to autofill on `evil.example.com`. This is the default behaviour of every major password manager, but worth disclosing.401402**No network traffic during autofill** from the vault code path itself. The one exception is the favicon fetch already flagged in ZK-003.403404### Finding EXT-001405406| Field | Value |407|---|---|408| Title | Autofill is click-gated and scoped to eTLD+1 of the form action |409| Category | Browser Extension |410| Severity | ☐ Critical  ☐ High  ☐ Medium  ☐ Low  ☒ Info |411| Status | ☒ Pass  ☐ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |412| Recommendation | Optional: add a Settings toggle for "Strict subdomain matching" that only autofills on exact hostname match. Niche but defensible for high-value entries. |413414### Finding EXT-002415416| Field | Value |417|---|---|418| Title | `nativeMessaging` permission is declared but unused in live code paths |419| Category | Browser Extension / Permissions |420| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |421| Status | ☐ Pass  ☒ Warn  ☐ Fail  ☐ Unverifiable  ☐ N/A |422| Evidence | `manifest.json:47`; `src/background/sync.ts:122` is the only caller of `chrome.runtime.connectNative`, and nothing calls `connectSyncHost`. The Rust sync host does not compile (see SYN-001) and no native-messaging host is installed by the extension today. |423| Impact | Minor — the extension requests a permission it does not currently exercise. Chrome Web Store reviewers and security-conscious users may ask why. |424| Recommendation | Gate the permission behind the Peach Sync feature flag, or remove it from the manifest until sync ships. |425426### Finding EXT-003 — `clipboardClearSeconds` setting is declared but never consumed427428| Field | Value |429|---|---|430| Title | Clipboard auto-clear is hardcoded to 30 s; the `clipboardClearSeconds` user setting is ignored |431| Category | Hygiene / Settings |432| Severity | ☐ Critical  ☐ High  ☐ Medium  ☒ Low  ☐ Info |433| Status | ☐ Pass  ☐ Warn  ☒ Fail  ☐ Unverifiable  ☐ N/A |434| Evidence | `VaultSettings.clipboardClearSeconds?: number` defined at `src/background/vault.ts:29` but the only reader is a Settings UI write. `src/background/clipboard.ts:29` uses a literal `30_000`. |435| Impact | Users who set this thinking it lowers their exposure window are misled. |436| Recommendation | Have `copyToClipboard` read `getSettings().clipboardClearSeconds ?? 30` and multiply by 1000. Two lines. |437438---439440## Scoring Rubric441442| Category | Weight | Guidance | Score (0–10) | Weighted | Notes |443|---|---|---|---|---|---|444| Cryptographic Algorithm Correctness | 20% | 10 = all algorithms correct & verified | **9** | 1.80 | AES-256-GCM, Argon2id, BLAKE3, X25519 all correct. Parameters meet/exceed OWASP. One doc gap (CRY-001) costs 1 point. |445| Zero-Knowledge Architecture Integrity | 20% | 10 = no network calls, no vault leakage | **7** | 1.40 | No vault content leaves the device. But the favicon fetch leaks the *set* of domains to DuckDuckGo — ZK-003. Two points off. |446| Network Behavior / Telemetry | 15% | 10 = zero unexpected calls | **7** | 1.05 | No telemetry SDK. HIBP is disclosed. DuckDuckGo favicon is **not** disclosed and not opt-in. |447| Sync Protocol Security | 15% | 10 = Noise handshake verified, LAN-only, unpaired rejected | **3** | 0.45 | The feature is marketed but not live. The native-host scaffold contains a critical zero-key bug (SYN-001) that must be fixed before ship. Scored low because the kit measures an existing claim — "Local-network peer-to-peer sync only" — that today works only because the feature is dormant, not because it is correctly implemented. |448| Backup & Restore Safety | 10% | 10 = passphrase required, tamper-detection works | **10** | 1.00 | BLAKE3 checksum, AEAD, constant-time compare, dual entry-count cross-check, full vault fidelity. |449| Memory Handling | 10% | 10 = canary absent from memory after lock | **7** | 0.70 | AES key zeroed. Plaintext strings linger until GC — fundamental JS limitation (MEM-001). |450| Duress Mode Integrity | 5% | 10 = vault file indistinguishable | **4** | 0.20 | Plaintext `peach_duress` flag + different-sized decoy blob + timing side channel (DUR-001, DUR-002). Deniability significantly weaker than claimed. |451| Browser Extension Security | 5% | 10 = no over-permissions, correct scoping | **8** | 0.40 | Click-gated autofill, eTLD+1 scoping, CSP set. One dormant permission (EXT-002) and one dead setting (EXT-003). |452| **TOTAL** | **100%** |  |  | **70.0 / 100** | |453454### Score interpretation455456**70 → Conditional.** Architecture and core cryptography are sound. Two material issues hold it back from a confident "Sound" verdict:4574581. **ZK-003** — favicons silently leak the domain set to a third party, contradicting the marketing tone.4592. **SYN-001** — the sync scaffold ships a critical zero-key responder bug. It does not execute today, but it will need to be fixed and re-audited before the Peach Sync feature is enabled.460461The duress findings (DUR-001, DUR-002) are also material if the feature is advertised as plausible-deniability-grade. They are small code changes.462463---464465## Part D — Verified Claim Status (peachpasswords.com)466467| Claim | Verdict | Evidence |468|---|---|---|469| "Your vault stays on your device. No server, no account." | ☒ Pass | No server, no account, no uploads. License is offline-verified (Ed25519). |470| "Sync it peer-to-peer across your devices." | ☒ Warn | Marketed, not live. Scaffolding exists; critical responder-key bug must be fixed (SYN-001). |471| "Zero-knowledge vault encryption" | ☒ Pass | AES-256-GCM + Argon2id + per-vault salt. Vault content never leaves the device. |472| "Zero server dependency" | ☒ Pass | Functions fully offline. HIBP is the only network dependency and degrades gracefully. |473| "No account creation" | ☒ Pass | Confirmed — no registration flow; licenses are offline-verified blobs. |474| "No product telemetry" | ☒ Warn | No analytics SDK, but the favicon path constitutes an undisclosed third-party callout (ZK-003). |475| "No cloud to breach" | ☒ Pass | Storage is local to the browser profile. CJK backups are user-held. |476| "Local-network peer-to-peer sync only" | ☐ N/A | Feature dormant in 1.4.24. |477| "AES-256-GCM for authenticated confidentiality" | ☒ Pass | Verified; fresh 96-bit nonce; 128-bit tag; version-byte AAD. |478| "Argon2id with per-vault salt and high-memory parameters" | ☒ Pass | m=65 536 / t=3 / p=4; per-vault 16-byte CSPRNG salt. |479| "Authenticated decryption rejects tampered phrases" | ☒ Pass | BLAKE3 checksum (constant-time) **before** passphrase prompt; AES-GCM AEAD after. |480| "k-anonymity prefix sent for breach checks" | ☒ Pass | 5-char SHA-1 prefix + `Add-Padding: true`. |481482---483484## Additional findings (EXTRA-00X)485486### Finding EXTRA-001 — Stale/duplicate AES module487488| Field | Value |489|---|---|490| Title | `src/crypto/aes.ts` is a near-duplicate of `src/background/aes.ts` and appears unused |491| Severity | ☒ Info |492| Status | ☒ Warn |493| Evidence | `src/background/vault.ts:8` imports from `./aes` (i.e., `src/background/aes.ts`). No other caller references `src/crypto/aes.ts`. |494| Recommendation | Delete the unused file to reduce audit surface and avoid confusion (future-me reading a finding will grep and get two hits). |495496### Finding EXTRA-002 — Minimum master-password length is only 8 characters497498| Field | Value |499|---|---|500| Title | `createVault` accepts an 8-character master password with no strength gate |501| Severity | ☒ Low |502| Status | ☒ Warn |503| Evidence | `src/popup/views/Lock.tsx:33-38` — `canCreate = password.length ≥ 8`. zxcvbn strength is displayed but not enforced. |504| Recommendation | Require zxcvbn ≥ 2 and length ≥ 12 for vault creation. (CJK export already enforces length ≥ 16 and zxcvbn ≥ 3; match that bar for the at-rest passphrase, or at least get close.) |505506### Finding EXTRA-003 — Redundant idle listener507508| Field | Value |509|---|---|510| Title | `chrome.idle.onStateChanged` is listened to twice (`entry.ts:12` and `alarms.ts:36`) |511| Severity | ☒ Info |512| Status | ☒ Warn |513| Evidence | Both files register listeners that call `lock()` on `state === "idle" | "locked"`. Harmless (calls are idempotent) but suggests refactor drift. |514| Recommendation | Keep the listener in `alarms.ts` only and remove the one in `entry.ts`. |515516---517518## Summary recommendation519520**Do not ship the sync feature in its current Rust state** (SYN-001). Before next public release:5215221. **Fix the favicon privacy leak** (ZK-003). Highest priority — it silently contradicts the Peach tagline.5232. **Harden duress mode storage** (DUR-001) and make the unlock timing constant (DUR-002).5243. **Tighten the master-password minimum** (EXTRA-002).5254. **Wire `clipboardClearSeconds`** (EXT-003) — two-line fix.5265. **Gate `nativeMessaging`** behind a sync feature flag (EXT-002).5276. **Update the audit kit** (CRY-001) to reflect the MV3 storage model and the v3 CJK header.528529Cryptographic core is solid. The privacy-posture concerns are fixable in a single focused PR; the sync concerns require a Rust-side fix and another round of review before the feature is turned on.