peach_audit_v1424.md
Peach — Internal Security Audit (v1.4.24)
Kit version: 1.0
Audit date: 2026-04-21
Auditor: June ([email protected]) / 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/<extension-id>/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).
// 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_vaultbefore 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 readingpersistEntries()insrc/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:
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 <all_urls> 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:
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,privateKeyJwkas 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
deriveKeyreturns; it is a stack-local variable and GC-eligible immediately. - Auto-lock fires after
autoLockMinutesminutes (default 5) and onchrome.idle.onStateChanged === "idle" | "locked". The idle listener is registered twice (entry.ts:12andalarms.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 fullStoredVaultwith an empty entries array) andchrome.storage.local.peach_duress = { enabled: true }. unlock(password)tries the main vault first; on failure, ifpeach_duress.enabled, tries the decoy.- A duress-unlocked session cannot leak into the real vault: all writes go to
peach_decoy_vaultviapersistEntries(checksvaultMode === "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: <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).
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:
- ZK-003 — favicons silently leak the domain set to a third party, contradicting the marketing tone.
- 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" |
| 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:
- Fix the favicon privacy leak (ZK-003). Highest priority — it silently contradicts the Peach tagline.
- Harden duress mode storage (DUR-001) and make the unlock timing constant (DUR-002).
- Tighten the master-password minimum (EXTRA-002).
- Wire
clipboardClearSeconds(EXT-003) — two-line fix. - Gate
nativeMessagingbehind a sync feature flag (EXT-002). - 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.