# Peach — Internal Security Audit (v1.4.24) **Kit version:** 1.0 **Audit date:** 2026-04-21 **Auditor:** June (hillofthemoon@proton.me) / assisted by Claude Code **Version under test:** Peach 1.4.24 **Build / commit:** `09386e1` (branch `main`) **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). ## How this audit diverges from the kit The 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: | Kit procedure | White-box analogue used here | |---|---| | `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)`. | | `grep argon2` in binary | Read `src/crypto/argon2.ts` and `src/crypto/cjkExport.ts`. | | `tshark` capture | Enumerate every `fetch(...)` / `chrome.runtime.connectNative(...)` / `chrome.tabs.sendMessage(...)` call site. | | `gcore $(pgrep peach)` | Inspect `lock()` zeroization logic and reason about JS string lifetimes. | | Little Snitch block | Read the code for any network precondition or server-fallback path. | Every "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)**. --- ## Module 1 — Environment Setup | # | Action | Findings | |---|---|---| | 1 | Install Peach fresh | N/A — built from source via `npm run build:chrome`. | | 2 | Baseline network capture | Substituted with static enumeration (see Module 3). | | 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). | | 4 | Stop capture | N/A. | | 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//000003.log` (LevelDB). Note: for white-box source-available audits, source-level grep replaces binary inspection. | **Environment checklist:** PASS — repo compiles clean (`npm run build:chrome`), unit tests pass (`npm test`), typecheck passes (`npm run typecheck` clean). --- ## Module 2 — Cryptographic Verification ### 2.1 Vault file format | # | Finding | |---|---| | 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. | | 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). | | 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. | | 4 | File-size growth tracks entry count non-linearly due to zstd compression (matches kit expectation). | | 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()`. | ### Finding CRY-001 | Field | Value | |---|---| | 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) | | Category | Documentation / Cryptography | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☐ Pass ☒ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "peach:v2:entries=N:zstd:aes256gcm:argon2id" header is visible in the vault file | | Procedure | §2.1 steps 1–2 | | 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:...`). | | Impact | An auditor running the kit verbatim will report FAIL for step 2 when the system is actually correct. Erodes confidence in subsequent findings. | | Recommendation | Update kit §2.1 to reflect MV3 storage model **and** bump the CJK header version to `peach:v3:...`. No code change required. | ### 2.2 Argon2id parameters Implementation: `src/crypto/argon2.ts` (at-rest vault) and `src/crypto/cjkExport.ts:38` (CJK backup). ```ts // At-rest vault — deriveKey() argon2id({ parallelism: 4, iterations: 3, memorySize: 65536, hashLength: 32 }) // CJK backup default ARGON2_DEFAULT = { memoryKiB: 65_536, iterations: 3, parallelism: 4 } // CJK backup high-security (selectable at export time) ARGON2_HIGH_SECURITY = { memoryKiB: 262_144, iterations: 4, parallelism: 4 } ``` Comparison against the kit's Appendix A (OWASP 2024): | Use case | OWASP minimum | Peach actual | Verdict | |---|---|---|---| | Interactive (bare minimum) | m=19 456, t=2, p=1 | m=65 536, t=3, p=4 | Exceeds on every axis | | Password Manager (recommended) | m=65 536, t=3, p=1 | m=65 536, t=3, p=4 | Equals on memory/iters, exceeds on parallelism | | High-security offline | m=262 144, t=4, p=1–4 | HIGH_SECURITY matches exactly | Matches | **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. Header 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. ### Finding CRY-002 | Field | Value | |---|---| | Title | Argon2id parameters meet or exceed OWASP minimums; parallelism=4 is a deliberate deviation | | Category | Cryptography | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "Argon2id with per-vault salt and high-memory parameters" | | Procedure | §2.2 steps 1–5 | | 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. | | 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. | | 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). | ### 2.3 GCM nonce management `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`). - **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. - **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`). - **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. - **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`. ### Finding CRY-003 | Field | Value | |---|---| | Title | AES-256-GCM uses random 96-bit nonce per encryption; AAD bound; tags verified | | Category | Cryptography | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "AES-256-GCM for authenticated confidentiality" | | Procedure | §2.3 steps 1–4 | | 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. | | Impact | No catastrophic nonce-reuse path. AEAD rejects tampered ciphertexts. | | Recommendation | None. | --- ## Module 3 — Zero-Knowledge & Network Behavior I enumerated every outbound network call in the codebase. Five call sites in total: | # | Call site | Endpoint | Trigger | Purpose | |---|---|---|---|---| | 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 | | 2 | `src/crypto/cjkPassphrase.ts:136` | `https://api.pwnedpasswords.com/range/{5 hex}` | Export passphrase validation | Same, for the export passphrase itself | | 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 | | 4 | `src/content/inject.ts:541` | `https://icons.duckduckgo.com/ip3/{domain}.ico` | Inline autofill selector inside every matched page | Favicon lookup | | 5 | `src/background/messages.ts:252,263` | User-supplied `https://...` webhook URL | Canary trigger (user-configured decoy entry) | User-chosen tripwire | There 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`. Native 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. ### 3.1 Baseline network behavior | # | Result | |---|---| | 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. | | 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**). | | 3 | **DNS / SYN / UDP**: only those emitted by the favicon and HIBP fetches. No mDNS (sync host not connected). No STUN/TURN. | | 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 }`). | | 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). | ### Finding ZK-001 | Field | Value | |---|---| | Title | Idle extension generates zero network traffic; vault operations are fully local | | Category | Zero-Knowledge Architecture | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Evidence | No timer / alarm calls `fetch()` or `connectNative()`. Only user-initiated actions trigger network I/O (save, export-validate, render-favicon). | | Impact | Confirms "no server, no account" and "if no peer is present, nothing leaves the device." | | Recommendation | Tighten once more: opening the popup should not eagerly fetch favicons from DuckDuckGo — see ZK-003. | ### 3.2 Breach monitoring Implementation: `src/background/vault.ts:66-86` (entry monitoring) and `src/crypto/cjkPassphrase.ts:127-168` (export passphrase check). Both use identical k-anonymity logic: ```ts const hashHex = sha1(password).toUpperCase(); // full 40-char hex const prefix = hashHex.slice(0, 5); // 5 hex chars only const suffix = hashHex.slice(5); // kept in-memory, never sent fetch(`https://api.pwnedpasswords.com/range/${prefix}`); ``` The 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. ### Finding ZK-002 | Field | Value | |---|---| | Title | Breach monitoring uses correct 5-hex k-anonymity; `Add-Padding` enabled | | Category | Zero-Knowledge / Network | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Evidence | `src/background/vault.ts:74-82`; `src/crypto/cjkPassphrase.ts:131-160`. Full password and full SHA-1 hash are never transmitted. | | 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. | ### 3.3 Telemetry investigation | # | Result | |---|---| | 1 | Full-session enumeration: only the four endpoint families in the table above. | | 2 | No mixpanel / amplitude / segment / posthog / sentry endpoints in source (`grep -iE "mixpanel\|amplitude\|segment\|posthog\|sentry\|telemetry" src/` → 0 matches). | | 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). | | 4 | No analytics SDK identifiers in source. | | 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. | ### Finding ZK-003 — favicons leak the full vault domain list to DuckDuckGo | Field | Value | |---|---| | Title | Vault UI and inline autofill selector fetch favicons from `icons.duckduckgo.com/ip3/*` for every entry domain | | Category | Zero-Knowledge / Network / Privacy | | Severity | ☐ Critical ☐ High ☒ Medium ☐ Low ☐ Info | | Status | ☐ Pass ☐ Warn ☒ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "Zero-knowledge vault encryption" and marketing copy suggesting no third parties see vault contents | | Procedure | §3.3 step 1; grep for `icons.duckduckgo.com` — four call sites. | | 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 `` when an autofill selector opens). Each call sends the bare registrable domain (e.g. `github.com.ico`) over HTTPS to DuckDuckGo. | | 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." | | 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. | --- ## Module 4 — Sync Protocol & Backup Audit ### 4.1 Peach Sync — LAN protocol verification The current state of sync in v1.4.24 is a **scaffold, not a live feature**: | Layer | State | |---|---| | 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. | | `src/background/sync.ts` (native-messaging bridge) | Defined, exports `connectSyncHost()`, but **no code path calls it**. | | 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`. | | `src/tcp.rs` (accept loop) | **Contains a critical latent bug** — see finding SYN-001 below. | | `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. | Because 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**: ### Finding SYN-001 — Responder uses all-zero static private key | Field | Value | |---|---| | Title | TCP accept loop instantiates the Noise NK responder with `[0u8; 32]` as its static private key | | Category | Cryptography / Sync Protocol | | Severity | ☒ Critical ☐ High ☐ Medium ☐ Low ☐ Info | | Status | ☐ Pass ☐ Warn ☒ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "Noise NK... mutual authentication (responder is authenticated via static key)" (from `peach_sync_spec.md`) | | Procedure | Review of `native/peach-sync-host/src/tcp.rs:78` | | 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. | | 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. | | 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. | ### 4.2 CJK phrase backup & restore Static review of `src/crypto/cjkExport.ts`, `cjkImport.ts`, `cjkPayload.ts`, `cjkSerialise.ts`: | # | Verdict | |---|---| | 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). | | 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`). | | 3 | Wrong passphrase → `DECRYPT_FAILED`. AEAD handles this. Verified via `src/crypto/cjkRoundTrip.test.ts` (passes). | | 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. | | 5 | Restore is complete: `serialiseVault` in v3 embeds every `VaultEntry` field (including passkey private JWK, canary webhooks, tags, deletedAt) plus `VaultSettings`. No lossy fields. | | 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`. | ### Finding BAK-001 | Field | Value | |---|---| | Title | CJK backup/restore is cryptographically complete and tamper-evident | | Category | Backup & Restore | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | 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). | | 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. | ### 4.3 Vault-file exfiltration resistance | # | Finding | |---|---| | 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. | | 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. | | 3 | Argon2id parameters force a ~0.3–1 s KDF pass per guess on attacker hardware. Back-of-envelope offline attack estimates: | | | • 6-char lowercase (26⁶ ≈ 3×10⁸ guesses) at 2 H/s ≈ 4.8 years on one GPU. | | | • 8-char mixed+digit (62⁸ ≈ 2.2×10¹⁴ guesses) at 2 H/s ≈ 3.5 million years. | | | • 20-char diceware at ~77 bits entropy: astronomically infeasible. | | 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. | ### Finding BAK-002 | Field | Value | |---|---| | Title | Offline attack cost is high for realistic passphrases; weak 6-char passwords remain weak | | Category | Backup / Cryptography | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | 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). | | 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. | | 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`). | --- ## Module 5 — Memory, Process & Edge-Case Testing ### 5.1 Memory forensics `lock()` in `src/background/vault.ts:260`: ```ts export function lock(): void { if (derivedKey) { zeroKey(derivedKey); // keyBytes.fill(0) — backing Uint8Array cleared derivedKey = null; } entries = []; // re-binds; old array is GC-eligible domainIndex = new Map(); vaultMode = "real"; clearAutoLock(); } ``` Static reasoning: - The **AES key** is zeroed in place. Good. - 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. - The **master passphrase** is never stored after `deriveKey` returns; it is a stack-local variable and GC-eligible immediately. - **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. ### Finding MEM-001 | Field | Value | |---|---| | Title | Derived AES key is zeroed on lock; plaintext entry strings linger in JS heap until GC | | Category | Memory Handling | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☐ Pass ☒ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Evidence | `zeroKey` fills the Uint8Array; `entries = []` does not. JS strings are immutable and cannot be zeroed. | | 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. | | 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. | ### 5.2 Duress password Implementation: `src/background/vault.ts:183-212, 241-254`. Behaviour: - Setup writes `chrome.storage.local.peach_decoy_vault` (a full `StoredVault` with an empty entries array) **and** `chrome.storage.local.peach_duress = { enabled: true }`. - `unlock(password)` tries the main vault first; on failure, if `peach_duress.enabled`, tries the decoy. - A duress-unlocked session cannot leak into the real vault: all writes go to `peach_decoy_vault` via `persistEntries` (checks `vaultMode === "decoy"`, line 536). Two problems weaken the "forensically plausible" claim the kit's scoring rubric measures: ### Finding DUR-001 — Duress mode is trivially detectable at the storage layer | Field | Value | |---|---| | Title | Enabling duress leaves a plaintext `peach_duress: { enabled: true }` flag and a separate `peach_decoy_vault` record in `chrome.storage.local` | | Category | Duress Mode | | Severity | ☐ Critical ☐ High ☒ Medium ☐ Low ☐ Info | | Status | ☐ Pass ☐ Warn ☒ Fail ☐ Unverifiable ☐ N/A | | Claim tested | "Peach claims a duress password shows a decoy empty vault... indistinguishable at the filesystem level" (kit §5.2) | | Procedure | `chrome.storage.local.get(null)` inspection | | 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). | | 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. | | 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. | ### Finding DUR-002 — Timing side-channel distinguishes duress unlock from main unlock | Field | Value | |---|---| | Title | `unlock()` tries the main vault first, decoy second, so duress unlock takes ~2× the Argon2id wall-clock of a main unlock | | Category | Duress Mode | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☐ Pass ☒ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Evidence | `src/background/vault.ts:227-258` — two sequential `await deriveKey(...)` calls on a duress path. | | 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." | | Recommendation | Always run both Argon2id derivations in parallel regardless of outcome; only surface the one that successfully decrypts. Total time becomes constant. | ### 5.3 Browser extension & autofill security **Chrome (`manifest.json`) permissions (MV3):** ``` "storage", "tabs", "activeTab", "scripting", "clipboardWrite", "alarms", "idle", "nativeMessaging", "webAuthenticationProxy" ``` **Firefox (`manifest.firefox.json`):** same minus `webAuthenticationProxy` (not supported by Gecko). | Permission | Used by | Justified today? | |---|---|---| | `storage` | Vault + settings + license + sync scaffolding | Yes | | `tabs` | Popup → active tab query, broadcasting `VAULT_UNLOCKED` | Yes | | `activeTab` | Autofill into current page | Yes | | `scripting` | Clipboard fallback injection (`src/background/clipboard.ts:9`) | Yes | | `clipboardWrite` | Copy password / TOTP | Yes | | `alarms` | Auto-lock | Yes | | `idle` | Auto-lock on system idle / screen lock | Yes | | `nativeMessaging` | *Declared but unused* — `connectNative` never invoked | **No (today)** — see EXT-002 | | `webAuthenticationProxy` | Passkey provider | Yes | **Content-script scope:** `` — 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`). **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. **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. **No network traffic during autofill** from the vault code path itself. The one exception is the favicon fetch already flagged in ZK-003. ### Finding EXT-001 | Field | Value | |---|---| | Title | Autofill is click-gated and scoped to eTLD+1 of the form action | | Category | Browser Extension | | Severity | ☐ Critical ☐ High ☐ Medium ☐ Low ☒ Info | | Status | ☒ Pass ☐ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | Recommendation | Optional: add a Settings toggle for "Strict subdomain matching" that only autofills on exact hostname match. Niche but defensible for high-value entries. | ### Finding EXT-002 | Field | Value | |---|---| | Title | `nativeMessaging` permission is declared but unused in live code paths | | Category | Browser Extension / Permissions | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☐ Pass ☒ Warn ☐ Fail ☐ Unverifiable ☐ N/A | | 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. | | Impact | Minor — the extension requests a permission it does not currently exercise. Chrome Web Store reviewers and security-conscious users may ask why. | | Recommendation | Gate the permission behind the Peach Sync feature flag, or remove it from the manifest until sync ships. | ### Finding EXT-003 — `clipboardClearSeconds` setting is declared but never consumed | Field | Value | |---|---| | Title | Clipboard auto-clear is hardcoded to 30 s; the `clipboardClearSeconds` user setting is ignored | | Category | Hygiene / Settings | | Severity | ☐ Critical ☐ High ☐ Medium ☒ Low ☐ Info | | Status | ☐ Pass ☐ Warn ☒ Fail ☐ Unverifiable ☐ N/A | | 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`. | | Impact | Users who set this thinking it lowers their exposure window are misled. | | Recommendation | Have `copyToClipboard` read `getSettings().clipboardClearSeconds ?? 30` and multiply by 1000. Two lines. | --- ## Scoring Rubric | Category | Weight | Guidance | Score (0–10) | Weighted | Notes | |---|---|---|---|---|---| | 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. | | 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. | | 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. | | 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. | | 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. | | 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). | | 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. | | 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). | | **TOTAL** | **100%** | | | **70.0 / 100** | | ### Score interpretation **70 → Conditional.** Architecture and core cryptography are sound. Two material issues hold it back from a confident "Sound" verdict: 1. **ZK-003** — favicons silently leak the domain set to a third party, contradicting the marketing tone. 2. **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. The duress findings (DUR-001, DUR-002) are also material if the feature is advertised as plausible-deniability-grade. They are small code changes. --- ## Part D — Verified Claim Status (peachpasswords.com) | Claim | Verdict | Evidence | |---|---|---| | "Your vault stays on your device. No server, no account." | ☒ Pass | No server, no account, no uploads. License is offline-verified (Ed25519). | | "Sync it peer-to-peer across your devices." | ☒ Warn | Marketed, not live. Scaffolding exists; critical responder-key bug must be fixed (SYN-001). | | "Zero-knowledge vault encryption" | ☒ Pass | AES-256-GCM + Argon2id + per-vault salt. Vault content never leaves the device. | | "Zero server dependency" | ☒ Pass | Functions fully offline. HIBP is the only network dependency and degrades gracefully. | | "No account creation" | ☒ Pass | Confirmed — no registration flow; licenses are offline-verified blobs. | | "No product telemetry" | ☒ Warn | No analytics SDK, but the favicon path constitutes an undisclosed third-party callout (ZK-003). | | "No cloud to breach" | ☒ Pass | Storage is local to the browser profile. CJK backups are user-held. | | "Local-network peer-to-peer sync only" | ☐ N/A | Feature dormant in 1.4.24. | | "AES-256-GCM for authenticated confidentiality" | ☒ Pass | Verified; fresh 96-bit nonce; 128-bit tag; version-byte AAD. | | "Argon2id with per-vault salt and high-memory parameters" | ☒ Pass | m=65 536 / t=3 / p=4; per-vault 16-byte CSPRNG salt. | | "Authenticated decryption rejects tampered phrases" | ☒ Pass | BLAKE3 checksum (constant-time) **before** passphrase prompt; AES-GCM AEAD after. | | "k-anonymity prefix sent for breach checks" | ☒ Pass | 5-char SHA-1 prefix + `Add-Padding: true`. | --- ## Additional findings (EXTRA-00X) ### Finding EXTRA-001 — Stale/duplicate AES module | Field | Value | |---|---| | Title | `src/crypto/aes.ts` is a near-duplicate of `src/background/aes.ts` and appears unused | | Severity | ☒ Info | | Status | ☒ Warn | | Evidence | `src/background/vault.ts:8` imports from `./aes` (i.e., `src/background/aes.ts`). No other caller references `src/crypto/aes.ts`. | | Recommendation | Delete the unused file to reduce audit surface and avoid confusion (future-me reading a finding will grep and get two hits). | ### Finding EXTRA-002 — Minimum master-password length is only 8 characters | Field | Value | |---|---| | Title | `createVault` accepts an 8-character master password with no strength gate | | Severity | ☒ Low | | Status | ☒ Warn | | Evidence | `src/popup/views/Lock.tsx:33-38` — `canCreate = password.length ≥ 8`. zxcvbn strength is displayed but not enforced. | | 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.) | ### Finding EXTRA-003 — Redundant idle listener | Field | Value | |---|---| | Title | `chrome.idle.onStateChanged` is listened to twice (`entry.ts:12` and `alarms.ts:36`) | | Severity | ☒ Info | | Status | ☒ Warn | | Evidence | Both files register listeners that call `lock()` on `state === "idle" | "locked"`. Harmless (calls are idempotent) but suggests refactor drift. | | Recommendation | Keep the listener in `alarms.ts` only and remove the one in `entry.ts`. | --- ## Summary recommendation **Do not ship the sync feature in its current Rust state** (SYN-001). Before next public release: 1. **Fix the favicon privacy leak** (ZK-003). Highest priority — it silently contradicts the Peach tagline. 2. **Harden duress mode storage** (DUR-001) and make the unlock timing constant (DUR-002). 3. **Tighten the master-password minimum** (EXTRA-002). 4. **Wire `clipboardClearSeconds`** (EXT-003) — two-line fix. 5. **Gate `nativeMessaging`** behind a sync feature flag (EXT-002). 6. **Update the audit kit** (CRY-001) to reflect the MV3 storage model and the v3 CJK header. Cryptographic 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.