2da1a2aff52c

fix(security): inline locked bubble no longer collects the master password in page DOM — routes to the trusted popup via OPEN_POPUP; removes page-observable keystroke surface and the trim/no-trim inconsistency (HIGH #3 + LOW #9) (v3.6.125)

time
2026-05-28 22:21 UTC · 5 months ago
machine
koh2
parent
c0204fa478df
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+9 −0src/content/inject.ts+28 −106src/shared/types.ts+1 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.124",5+  "version": "3.6.125",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.124",4+  "version": "3.6.125",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.124",3+  "version": "3.6.125",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+9 −0
@@ -811,6 +811,15 @@811811         try { await chrome.action.openPopup(); } catch { /* badge fallback */ }812812         return { ok: true };813813       }814+      case "OPEN_POPUP": {815+        // Content script asks to open the trusted extension popup — used by the816+        // inline locked bubble so the master password is entered in the popup,817+        // never in page-hosted DOM (audit HIGH). Best-effort: openPopup needs a818+        // user gesture + Chrome 127+/Firefox; on failure the user clicks the819+        // toolbar icon. The badge already signals Peach needs attention.820+        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }821+        return { ok: true };822+      }814823       case "GET_IDENTITIES": {815824         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };816825         return { ok: true, data: vault.getIdentities() };

view file

src/content/inject.ts+28 −106
@@ -954,6 +954,7 @@954954     display: flex; align-items: center; justify-content: center;955955   }956956   .locked-title { font-size: 11.5px; color: var(--peach-text); font-weight: 500; }957+  .locked-sub { font-size: 10.5px; line-height: 1.4; color: var(--peach-text-muted); text-align: center; margin: 2px 0 2px; }957958   .locked-input {958959     width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);959960     border-radius: 7px; color: var(--peach-text); padding: 7px 10px;@@ -2543,105 +2544,38 @@25432544     title.textContent = "unlock to autofill";25442545     head.append(icon, title);25452546 2546-    // Password input2547-    const pwInp = document.createElement("input");2548-    pwInp.className = "locked-input";2549-    pwInp.type = "password";2550-    pwInp.placeholder = "master password";2551-    pwInp.setAttribute("autocomplete", "current-password");2547+    // The master password is NEVER collected in page-hosted DOM. A malicious2548+    // page can observe keystrokes on the shared DOM event stream (a capture-2549+    // phase listener registered before Peach injects sees every key before any2550+    // stopImmediatePropagation), which would leak the vault's root secret2551+    // (audit HIGH). Instead, route unlocking to the trusted extension popup,2552+    // where keystrokes are isolated from the page. After the user unlocks2553+    // there, the background's VAULT_UNLOCKED broadcast re-triggers autofill2554+    // automatically (see content/index.ts), so the flow resumes on its own.2555+    const sub = document.createElement("div");2556+    sub.className = "locked-sub";2557+    sub.textContent = "Unlock in Peach — your master password is never typed on this page.";25522558 2553-    // Error line (hidden initially)2554-    const errEl = document.createElement("div");2555-    errEl.className = "locked-error";2556-    errEl.style.display = "none";2557-2558-    // Unlock button2559-    const unlockBtn = document.createElement("button");2560-    unlockBtn.className = "locked-btn";2561-    unlockBtn.type = "button";2562-    unlockBtn.textContent = "unlock";2563-2564-    async function tryUnlock() {2565-      const pw = pwInp.value.trim();2566-      if (!pw) { pwInp.focus(); return; }2567-      unlockBtn.disabled = true;2568-      unlockBtn.textContent = "unlocking…";2569-      errEl.style.display = "none";2570-      try {2571-        const res = await chrome.runtime.sendMessage({2572-          type: "UNLOCK",2573-          payload: { password: pw },2574-        });2575-        if ((res as { ok?: boolean }).ok) {2576-          dismiss();2577-          options.onUnlocked?.();2578-        } else {2579-          errEl.textContent = "incorrect password";2580-          errEl.style.display = "block";2581-          pwInp.value = "";2582-          pwInp.focus();2583-          unlockBtn.disabled = false;2584-          unlockBtn.textContent = "unlock";2559+    const openBtn = document.createElement("button");2560+    openBtn.className = "locked-btn";2561+    openBtn.type = "button";2562+    openBtn.textContent = "Open Peach to unlock";2563+    openBtn.addEventListener("click", () => {2564+      chrome.runtime.sendMessage({ type: "OPEN_POPUP" }).catch(() => {});2565+      openBtn.textContent = "opening Peach…";2566+      openBtn.disabled = true;2567+      // openPopup() can't always open programmatically (browser/version/gesture2568+      // limits); reset so the user can retry or click the toolbar icon.2569+      setTimeout(() => {2570+        if (openBtn.isConnected) {2571+          openBtn.textContent = "Open Peach to unlock";2572+          openBtn.disabled = false;25852573         }2586-      } catch {2587-        errEl.textContent = "could not reach Peach";2588-        errEl.style.display = "block";2589-        unlockBtn.disabled = false;2590-        unlockBtn.textContent = "unlock";2591-      }2592-    }2593-2594-    pwInp.addEventListener("keydown", (e) => {2595-      e.stopPropagation();2596-      if (e.key === "Enter") void tryUnlock();2574+      }, 2500);25972575     });2598-    pwInp.addEventListener("keyup", (e) => e.stopPropagation());2599-    pwInp.addEventListener("keypress", (e) => e.stopPropagation());2600-    unlockBtn.addEventListener("click", () => void tryUnlock());26012576 2602-    wrap.append(head, pwInp, errEl, unlockBtn);2577+    wrap.append(head, sub, openBtn);26032578     container.appendChild(wrap);2604-2605-    // Keep the MV3 service worker alive while the locked popup is visible.2606-    // Without this, the SW goes idle (~30 s) while the user types their2607-    // password, and the UNLOCK message pays a 100-300 ms wakeup penalty2608-    // before the crypto even starts — perceptible as a delay vs the always-2609-    // live extension popup page.2610-    const keepaliveId = setInterval(() => {2611-      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});2612-    }, 20_000);2613-    cleanupHooks.push(() => clearInterval(keepaliveId));2614-2615-    // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)2616-    // the page reclaims focus from our Shadow DOM input before the user can2617-    // type. Fighting over focus with a focusout→refocus loop hangs the tab.2618-    // Instead: intercept keystrokes at the document capture phase — this runs2619-    // before any page handler regardless of which element technically has2620-    // OS focus — and route them into our input.2621-    const captureKeydown = (e: KeyboardEvent) => {2622-      if (!host.isConnected) {2623-        document.removeEventListener("keydown", captureKeydown, true);2624-        return;2625-      }2626-      // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.2627-      if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;2628-      // Intercept before page handlers and give our input focus.2629-      e.stopImmediatePropagation();2630-      if (e.key === "Escape") { dismiss(true); return; }2631-      if (e.key === "Enter") { void tryUnlock(); return; }2632-      // For all other printable/editing keys, ensure our input has focus so2633-      // the browser delivers the keystroke there naturally.2634-      if (document.activeElement !== host) {2635-        pwInp.focus({ preventScroll: true });2636-      }2637-    };2638-    captureKeydownRef = captureKeydown;2639-    document.addEventListener("keydown", captureKeydown, true);2640-2641-    // Best-effort focus on open — may be overridden by page focus trap,2642-    // but the capture listener above handles typing regardless.2643-    pwInp.focus({ preventScroll: true });2644-    requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });26452579   }26462580 26472581   function handleKeyDown(e: KeyboardEvent) {@@ -2707,14 +2641,6 @@27072641     host.style.left = `${r.left + window.scrollX}px`;27082642     host.style.width = `${popupWidth}px`;27092643   }2710-  // These bindings are referenced from renderLocked()/dismiss() — they MUST2711-  // be initialized before renderLocked() runs, or the synchronous assignment2712-  // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ2713-  // and throws ReferenceError. The throw lands after the popup is rendered2714-  // but before showAutofillSelector returns its dismiss handle, leaving an2715-  // orphan host in the DOM (which is what caused the locked-popup stacking2716-  // and missing autofocus on every click).2717-  let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;27182644   const stopObserving = observeAnchorPosition(anchorEl, updatePosition);27192645 27202646   if (options.locked) {@@ -2744,10 +2670,6 @@27442670     stopObserving();27452671     document.removeEventListener("click", outsideClick, { capture: true });27462672     document.removeEventListener("keydown", handleKeyDown);2747-    if (captureKeydownRef) {2748-      document.removeEventListener("keydown", captureKeydownRef, true);2749-      captureKeydownRef = null;2750-    }27512673     for (const cleanup of cleanupHooks) {27522674       try { cleanup(); } catch { /* defensive — one hook must not block others */ }27532675     }

view file

src/shared/types.ts+1 −0
@@ -319,6 +319,7 @@319319   | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free320320   | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup321321   | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section322+  | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)322323   | "SHOW_PASSKEY_TOAST"    // background → content tab: show passkey auth toast323324   | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast324325   | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast

view file