c3c0d13e0280
fix: gate key-material and pairing handlers to extension-page senders (v4.8.2)
- time
- 2026-08-04 14:26 UTC · 2 months ago
- machine
- koh2
- parent
- 1fc1bb91666b
↓ peach-browser-c3c0d13e.tar.gzatlas at this save
files changed6 files
AUDIT_4.4.6.md+0 −199manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+44 −1src/background/messages.ts+26 −12AUDIT_4.4.6.md+0 −199
@@ -1,199 +0,0 @@1-# Peach Browser Extension — Security & Reliability Audit2-3-**Version:** 4.4.6 4-**Audit date:** 2026-07-30 5-**Scope:** Full source audit (read-only), delta from v4.0.0 6-**Auditor:** Codex agent (GPT-5.6) 7-**Prior audit:** v4.0.0 8-9----10-11-## Executive Summary12-13-Peach v4.4.6 is **fit for production**. No critical or high-severity vulnerabilities were identified. The codebase demonstrates mature security engineering: a Rust-compiled Noise protocol core, per-entry authenticated encryption with key separation, constant-time comparisons at every authenticated site, Ed25519-signed device manifests with freshness windows, and a well-bounded sync receive path. The changes since v4.0.0 (recovery hardening, pairing/adoption, SAS verification, decentralized mesh convergence) are implemented safely and follow the established security patterns.14-15----16-17-## Findings by Threat Level18-19-### CRITICAL20-21-None identified.22-23-### HIGH24-25-None identified.26-27-### MEDIUM28-29-#### M-1: Session key persisted in `chrome.storage.session` (accepted risk)30-31-- **Location:** `src/background/vault.ts:1390–1410`32-- **Detail:** The AES-256 derived key is stored hex-encoded in `chrome.storage.session` to survive MV3 service-worker restarts. This storage is memory-backed and cleared on browser close, but a same-session XSS in an extension page (not a web page) could theoretically read it.33-- **Mitigation in place:** Extension pages have a strict CSP (`script-src 'self' 'wasm-unsafe-eval'; object-src 'none'`), no `eval`, no remote scripts. The content script (which runs in page context) cannot access `chrome.storage.session`. Destructive operations are gated to extension-origin senders.34-- **Verdict:** Accepted MV3 tradeoff. The alternative (re-prompt on every SW restart) is a worse UX with no meaningful security gain given the CSP posture.35-36-#### M-2: Tombstone accumulation without bound37-38-- **Location:** `src/background/sync-vault-adapter.ts:115–130`39-- **Detail:** Every deleted entry leaves a `{id, deletedAt}` tombstone that persists indefinitely and is sent in every sync session. A vault with tens of thousands of deletions over years will grow its sync manifest linearly.40-- **Mitigation in place:** `SYNC_LIMITS.MAX_TOMBSTONE_COUNT` (100,000) caps a single sync payload. Trash-retention purge eventually splices the entry array. Records are tiny (id + timestamp).41-- **Verdict:** Reliability concern for extreme long-term use, not a security issue. Documented in-code as a known limitation with a future pruning path.42-43-#### M-3: Recovery words encode 128 bits of a 256-bit root key44-45-- **Location:** `src/shared/recovery-words.ts:25–30`, `src/background/vault.ts:300–310`46-- **Detail:** The 12-word BIP39 phrase encodes only `rootKey[0..16]`. The recovery KEK is derived from a prefix of the plaintext it wraps. Effective recovery security is 128-bit, not 256-bit.47-- **Mitigation in place:** Documented extensively in-code (PCH-01-005). 128-bit security is the standard for BIP39 12-word phrases and is computationally infeasible to brute-force. The GCM ciphertext leaks nothing without the words.48-- **Verdict:** Intentional design, well-documented, standard security level for mnemonic recovery.49-50-### LOW51-52-#### L-1: `console.log`/`console.warn` statements in production code (76 instances)53-54-- **Location:** Various (`src/background/`, `src/shared/`)55-- **Detail:** 76 console statements remain in non-test source. None log secrets (keys, passwords, entry content) — verified by inspection. They log sync stage names, error messages, and diagnostic counters.56-- **Verdict:** No information leakage risk. Cosmetic/hygiene issue only.57-58-#### L-2: No lint script configured59-60-- **Location:** `package.json`61-- **Detail:** The project has no `lint` script. Type checking (`tsc --noEmit`) and the full test suite (3,638 tests) pass, but there is no static analysis for common JS/TS pitfalls (unused vars, implicit any, etc.).62-- **Verdict:** Reliability hygiene gap, not a security issue. The strict TypeScript config and comprehensive test suite compensate substantially.63-64-#### L-3: `unlockWithKey` accepts a raw hex key without Argon2id65-66-- **Location:** `src/background/vault.ts:1466`67-- **Detail:** Used by Android biometric unlock where the key was derived natively. The browser extension never calls this path from user input — it is only reachable via the Capacitor/native bridge.68-- **Verdict:** Correct separation of concerns. The native side owns the KDF for biometric unlock.69-70-#### L-4: HIBP breach check uses SHA-1 (dictated by API)71-72-- **Location:** `src/background/vault.ts:230–250`73-- **Detail:** The Have I Been Pwned k-anonymity API requires SHA-1 prefixes. This is not a Peach design choice. Response padding is requested (`Add-Padding: true`).74-- **Verdict:** No action possible; the API dictates the hash. The k-anonymity model (5-char prefix only) means the full password hash never leaves the device.75-76-### INFORMATIONAL77-78-#### I-1: Duress slot size ceiling (256 KiB)79-80-- **Location:** `src/background/vault.ts:70–75`81-- **Detail:** Both slots are padded to a fixed 256 KiB ceiling when duress is configured. A vault exceeding this would re-expose the duress bit via ciphertext length divergence. Documented as an extreme edge case.82-83-#### I-2: Subnet probe retired84-85-- **Location:** `src/background/sync-runner.ts:1–15`86-- **Detail:** LAN peer-to-peer sync is retired (`docs/sync-lan-retirement.md`). The subnet probe code remains in `sync-transport.ts` but is unreachable from the production runner. The relay is the sole transport.87-88-#### I-3: E2E passkey bypass compile-time flag89-90-- **Location:** `src/background/messages.ts:95`, `src/background/passkey.ts:25`91-- **Detail:** `__PEACH_E2E_PASSKEYS__` is folded to `false` by Vite's `define` in every production build. Verified in the bundled output by the build script. The gate is fully intact for users.92-93----94-95-## Security Architecture Assessment96-97-### Cryptography ✅98-99-| Component | Implementation | Assessment |100-|-----------|---------------|------------|101-| KDF | Argon2id (m=64MiB, t=3, p=4) | Exceeds OWASP password-manager tier |102-| Vault encryption | AES-256-GCM, random 96-bit nonce, 128-bit tag, AAD domain separation | Correct |103-| Per-entry sync encryption | HKDF-SHA256 per entry ID, AES-GCM with entry-bound AAD | Prevents slot-swap attacks |104-| Key zeroing | `zeroKey()` on all ephemeral/derived keys after use | Consistent |105-| Self-test | PBKDF2+AES-GCM KAT on every unlock | Catches broken WebCrypto |106-| Cross-platform KAT | Argon2id vector coordinated with Android BouncyCastle | Prevents silent param drift |107-108-### Protocol Security ✅109-110-| Component | Implementation | Assessment |111-|-----------|---------------|------------|112-| Initial pairing | Noise_NK_25519_ChaChaPoly_BLAKE2s (Rust `snow` → WASM) | Correct pattern choice |113-| Reconnect | Noise_KK with both static keys, no NK fallback | Prevents downgrade |114-| Post-handshake auth | HMAC-SHA256 bound to handshake hash + DH + domain separation | Prevents session hijack |115-| SAS | 8-digit from handshake hash (64 PRF bits, negligible modulo bias) | Correct |116-| Device manifest | Ed25519 signed, canonical JSON, ±freshness window, version-pinned | Correct |117-| Revocation | Terminal, key-matched (not just id-matched), self-revocation filtered | Prevents re-admission |118-119-### Input Validation ✅120-121-- Sync frame size: 24 MiB hard cap (MV-3)122-- Entry count: 100,000 per payload123-- Entry plaintext: 512 KiB per entry124-- Tombstone count: 100,000 per payload125-- Timestamp clamping: ±5 minutes forward skew (HV-1)126-- Pairing invitation: 128-bit bearer token, base64url validated127-- Adoption frames: strict type guards on every field128-- Relay URLs: `wss:` only, no credentials in URL129-130-### XSS / Injection Surface ✅131-132-- Content script `innerHTML`: all assignments are static SVG strings or typed numbers — no page-derived data reaches markup133-- Note editor: allow-list sanitizer (12 tags, all attributes stripped)134-- Popup: SolidJS (auto-escaping by default), no `dangerouslySetInnerHTML`135-- CSP: `script-src 'self' 'wasm-unsafe-eval'; object-src 'none'`136-- Message handler: sender validation, destructive ops gated to extension origin137-138-### Reliability ✅139-140-- 164 test files, 3,638 passing tests + 40 todo141-- Typecheck passes (`tsc --noEmit`)142-- Chrome/Firefox/Mobile production builds pass143-- MV3 static-import guard (build fails on runtime `import()` in worker graph)144-- Clipboard clear: eviction-proof (chrome.alarms + offscreen document + retry)145-- Auto-lock: alarm-based + idle detection, recovery-confirm exemption146-- Sync timeout: 30s per session, stage-level diagnostics147-- Mesh listener: bounded retry with exponential backoff, abort on lock148-149----150-151-## Delta Assessment (v4.0.0 → v4.4.6)152-153-| Change Area | Versions | Safety Verdict |154-|-------------|----------|----------------|155-| Recovery flow hardening (try/finally, canvas ref fix) | 4.0.x | ✅ Safe |156-| Onboarding reveal toggle + copy | 4.0.x | ✅ Safe |157-| Playwright smoke tests (recovery, popup lifecycle) | 4.0.x | ✅ Safe |158-| Pairing/adoption visual QA (QR, SAS, success states) | 4.4.4–4.4.5 | ✅ Safe (CSS/layout only) |159-| CSS cascade fix (compact selector specificity) | 4.4.5 | ✅ Safe |160-| Decentralized mesh convergence (deviceManifestVersion: 3) | 4.4.6 | ✅ Safe |161-| Vouched trust source promotion (direct/vouched → equal authority) | 4.4.6 | ✅ Safe |162-| SYNC_DEVICE_MANIFEST on equal entry versions | 4.4.6 | ✅ Safe |163-164-The mesh convergence change (v4.4.6) is the highest-risk delta. It was implemented correctly:165-- Capability-gated: peers that omit `deviceManifestVersion` retain the legacy fast path166-- Authority model: any admitted, Noise-authenticated peer with a valid signing key may propagate; unknown/revoked keys remain blocked167-- Revocation ordering: absorbed before vouching in the same manifest to prevent race168-- Self-revocation filtered: a peer cannot revoke the receiver's own device169-170----171-172-## Production Worthiness173-174-**Verdict: APPROVED FOR PRODUCTION**175-176-The codebase demonstrates:177-- Defense-in-depth at every trust boundary (transport + envelope encryption)178-- Constant-time comparisons at all authenticated sites179-- Strict input validation with bounded resource consumption180-- Comprehensive test coverage (3,638 tests) with adversarial and KAT suites181-- Clean separation between the content-script attack surface and the privileged background182-- Documented security model with inline rationale at every non-obvious decision183-- No hardcoded secrets, no eval, no remote code loading184-- MV3 compliance with static-import enforcement185-186-No blocking issues. The MEDIUM findings are accepted design tradeoffs with appropriate mitigations. The LOW findings are hygiene items that do not affect security posture.187-188----189-190-## Recommendations (non-blocking)191-192-1. Add a lint script (ESLint or Biome) to CI for ongoing hygiene.193-2. Consider pruning tombstones older than N months once all peers confirm receipt.194-3. Reduce console statements to structured debug logging behind a compile-time flag.195-4. Document the duress 256 KiB ceiling in user-facing materials for extreme vault sizes.196-197----198-199-*End of audit.*manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.8.1",4+ "version": "4.8.2",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.8.1",5+ "version": "4.8.2",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.8.1",3+ "version": "4.8.2",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.test.ts+44 −1
@@ -1214,10 +1214,53 @@12141214 await expect(dispatch({12151215 type: "COMPLETE_PAIRING",12161216 payload: { paired: phone },1217- })).resolves.toMatchObject({ ok: true });1217+ }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" }))1218+ .resolves.toMatchObject({ ok: true });12181219 12191220 expect(await isDeviceRevoked(phone.deviceId, phone.longTermPublicKeyHex)).toBe(false);12201221 expect(await findPairedDeviceById(phone.deviceId)).toMatchObject(phone);1222+ });1223+1224+ it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1225+ const vault = await import("./vault");1226+ await vault.createVault("correct-pass-12345");1227+ const contentScriptSender = {1228+ id: "peach-extension",1229+ url: "https://evil.example/page",1230+ };1231+ const extensionPageSender = {1232+ id: "peach-extension",1233+ url: "chrome-extension://peach-extension/popup.html",1234+ };1235+ const paired = {1236+ deviceId: "attacker-1",1237+ deviceName: "Attacker Device",1238+ deviceType: "android" as const,1239+ longTermPublicKeyHex: "c".repeat(64),1240+ signingPublicKeyHex: "d".repeat(64),1241+ pairedAt: 1_700_000_000_000,1242+ trustSource: "direct" as const,1243+ };1244+1245+ // Content-script senders must never reach key-material or pairing handlers.1246+ await expect(1247+ dispatch({ type: "GET_ADOPTION_MATERIAL" }, contentScriptSender)1248+ ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });1249+ await expect(1250+ dispatch({ type: "COMPLETE_PAIRING", payload: { paired } }, contentScriptSender)1251+ ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });1252+ await expect(1253+ dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { requestId: 1, rpId: "evil.example" } }, contentScriptSender)1254+ ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });1255+1256+ // The paired-device row must NOT have been registered by the rejected call.1257+ const { findPairedDeviceById } = await import("./paired-devices");1258+ expect(await findPairedDeviceById(paired.deviceId)).toBeNull();1259+1260+ // Extension-page senders still reach GET_ADOPTION_MATERIAL.1261+ await expect(1262+ dispatch({ type: "GET_ADOPTION_MATERIAL" }, extensionPageSender)1263+ ).resolves.toMatchObject({ ok: true });12211264 });12221265 12231266 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {src/background/messages.ts+26 −12
@@ -160,15 +160,27 @@160160 }161161 162162 // ---------------------------------------------------------------------------163-// PCH-01-018: operations that irreversibly destroy or replace vault contents.164-// These are gated to trusted extension-page senders in handleMessage; the165-// confirmation UX otherwise lives only in the popup. Keep this in sync when166-// adding new destructive handlers.167-const DESTRUCTIVE_MESSAGE_TYPES: ReadonlySet<string> = new Set([163+// PCH-01-018: operations that irreversibly destroy or replace vault contents,164+// plus handlers that export key material or register paired devices. These are165+// gated to trusted extension-page senders in handleMessage; the confirmation166+// UX otherwise lives only in the popup. Keep this in sync when adding new167+// destructive or sensitive handlers.168+// Codex Security 2026-08-04 (HIGH): GET_ADOPTION_MATERIAL (returns the root169+// key) and COMPLETE_PAIRING (registers a device, lifts its revocation, and170+// triggers sync) were reachable from any content script on isUnlocked() alone;171+// COMPLETE_FIREFOX_PASSKEY_CREATE (MEDIUM) stored a passkey for a172+// caller-chosen rpId with no ceremony binding. All three now require a173+// trusted extension-page sender; GET_ADOPTION_MATERIAL/COMPLETE_PAIRING have174+// no legitimate runtime senders (adoption runs in-background via175+// relay-adoption-bg.ts) and are deletion candidates post-release.176+const PRIVILEGED_MESSAGE_TYPES: ReadonlySet<string> = new Set([168177 "DESTROY_VAULT",169178 "CLEAR_VAULT_ENTRIES",170179 "RESTORE_VAULT_SNAPSHOT",171180 "RESTORE_FROM_CODEX",181+ "GET_ADOPTION_MATERIAL",182+ "COMPLETE_PAIRING",183+ "COMPLETE_FIREFOX_PASSKEY_CREATE",172184 ]);173185 174186 // LV-3: minimum interval between non-test canary pings per webhook+entry.@@ -288,13 +300,15 @@288300 return { ok: false, error: "invalid_sender" };289301 }290302 291- // PCH-01-018: irreversible operations must originate from a trusted extension292- // page (popup/options), never from a content script. The sender.id check above293- // only proves the message came from *our* extension — a content script injected294- // into an attacker page passes it. A single XSS-class bug in the injected UI295- // would otherwise escalate to vault destruction. Extension pages carry an296- // extension-origin sender.url; content scripts carry a page URL.297- if (DESTRUCTIVE_MESSAGE_TYPES.has(msg.type)) {303+ // PCH-01-018: destructive and sensitive operations must originate from a304+ // trusted extension page (popup/options), never from a content script. The305+ // sender.id check above only proves the message came from *our* extension —306+ // a content script injected into an attacker page passes it. A single307+ // XSS-class bug in the injected UI would otherwise escalate to vault308+ // destruction, root-key exfiltration, or attacker device registration.309+ // Extension pages carry an extension-origin sender.url; content scripts310+ // carry a page URL.311+ if (PRIVILEGED_MESSAGE_TYPES.has(msg.type)) {298312 const extensionOrigin = chrome.runtime.getURL("");299313 if (!sender.url?.startsWith(extensionOrigin)) {300314 return { ok: false, error: "invalid_sender" };