3bc26b9c7542

fix(security): GET_ENTRY_FOR_FILL sends 6-digit TOTP code not seed — autofill+tray can't derive future codes; edit form uses new GET_ENTRY_FOR_EDIT to keep the seed; correct stale HKDF comment in chirp-sync-vault-adapter (v3.6.116)

time
2026-05-28 20:16 UTC · 5 months ago
machine
koh2
parent
9a0edc58db45
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-sync-vault-adapter.ts+9 −6src/background/messages.test.ts+37 −0src/background/messages.ts+33 −4src/content/index.ts+7 −5src/content/inject.ts+32 −16src/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.115",5+  "version": "3.6.116",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.115",4+  "version": "3.6.116",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.115",3+  "version": "3.6.116",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/chirp-sync-vault-adapter.ts+9 −6
@@ -6,12 +6,15 @@66 // vault as encrypted envelopes here, and incoming envelopes are decrypted77 // here before being merged via `applyMergedRemoteEntries`.88 //9-// Envelope cipher: AES-GCM(key = HKDF(masterKey, salt="peach-sync-entry-v1"),10-//                          nonce = random 12B, aad = "peach-sync-entry-v1").11-// Both sides hold the same masterKey (same vault) so the symmetric key12-// matches without prior negotiation. The Noise XX session encrypts the13-// transport on top of this — the per-entry envelope is belt-and-braces so a14-// transport-layer leak doesn't expose plaintext entries.9+// Envelope cipher: AES-GCM with a PER-ENTRY key derived as10+//   HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "sync-entry-v1")11+// (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the12+// entry id means a ciphertext minted for one entry slot cannot be replayed13+// into another: decryptEntryOrNull rejects any envelope whose decrypted14+// entry.id does not match the map key it arrived under. Both sides hold the15+// same syncMasterKey (same vault) so keys match without prior negotiation. The16+// Noise XX session encrypts the transport on top — the envelope is17+// belt-and-braces so a transport-layer leak doesn't expose plaintext entries.1518 //1619 // Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is1720 // the trust anchor: manifests are signed with its vault-derived Ed25519 key,

view file

src/background/messages.test.ts+37 −0
@@ -790,6 +790,43 @@790790     expect(storage.peach_pro_celebrated).toBe(true);791791   });792792 793+  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {794+    const vault = await import("./vault");795+    await vault.createVault("correct-pass-12345");796+    const e = await vault.addEntry({797+      title: "Mail",798+      username: "june",799+      password: "pw",800+      url: "https://mail.test",801+      totp: "JBSWY3DPEHPK3PXP",802+    });803+804+    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });805+    expect(res.ok).toBe(true);806+    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };807+    expect(data.totp).toBeUndefined();808+    expect(data.totpCode).toMatch(/^\d{6}$/);809+    expect(data.totpRemaining).toBeGreaterThan(0);810+    expect(data.totpRemaining).toBeLessThanOrEqual(30);811+  });812+813+  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {814+    const vault = await import("./vault");815+    await vault.createVault("correct-pass-12345");816+    const e = await vault.addEntry({817+      title: "Mail",818+      username: "june",819+      password: "pw",820+      url: "https://mail.test",821+      totp: "JBSWY3DPEHPK3PXP",822+    });823+824+    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });825+    expect(res.ok).toBe(true);826+    const data = res.data as { totp?: string };827+    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");828+  });829+793830   it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {794831     const vault = await import("./vault");795832     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+33 −4
@@ -53,6 +53,7 @@5353   VaultEntry,5454 } from "../shared/types";5555 import { isPasskeyOnly } from "../shared/types";56+import { generateTOTP } from "../crypto/totp";5657 import { isNativePlatform, nativePlugin } from "../shared/native";5758 import { predictBatch as mlPredictBatch } from "./ml";5859 import type { MLFieldFeatures } from "../shared/ml";@@ -576,10 +577,38 @@576577         // filled into a form — sign-in goes through the WebAuthn path. A577578         // login that ALSO has a passkey is still fillable for its password.578579         if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };579-        // Strip the passkey credential from the response — content scripts580-        // never need the private key, and there's no reason to expose the581-        // public credential metadata over the boundary either.582-        const { passkey: _strippedPasskey, ...safeEntry } = entry;580+        // Strip the passkey credential AND the raw base32 TOTP seed from the581+        // response. Content scripts never need either: the private key is the582+        // authenticator's, and handing over the TOTP seed would let a583+        // compromised content script mint valid codes indefinitely (SEV-3-C).584+        // Generate the *current* 6-digit code here in the background and send585+        // only that, plus the seconds left so the UI can render a countdown.586+        const { passkey: _strippedPasskey, totp: seed, ...safeEntry } = entry;587+        let totpCode: string | undefined;588+        let totpRemaining: number | undefined;589+        if (seed) {590+          try {591+            const generated = generateTOTP(seed);592+            totpCode = generated.code;593+            totpRemaining = generated.remaining;594+          } catch {595+            totpCode = undefined;596+          }597+        }598+        return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };599+      }600+      // Trusted full-entry fetch for Peach's own inline-popup surfaces that601+      // genuinely need the raw TOTP seed: the edit form (which re-saves the602+      // seed and would otherwise wipe it) and nothing else. Unlike603+      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside604+      // the inline popup, never during passive autofill on an arbitrary page.605+      // The passkey credential is still stripped — the edit form never touches it.606+      case "GET_ENTRY_FOR_EDIT": {607+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };608+        const { id } = msg.payload as { id: string };609+        const entry = vault.getEntryById(id);610+        if (!entry) return { ok: false, error: "entry_not_found" };611+        const { passkey: _passkey, ...safeEntry } = entry;583612         return { ok: true, data: safeEntry };584613       }585614       case "COPY": {

view file

src/content/index.ts+7 −5
@@ -30,7 +30,6 @@3030 import { contentMLDetector } from "./content-ml-detector";3131 import { detectPasswordPolicy } from "./policy";3232 import type { FieldPrediction } from "../shared/ml";33-import { generateTOTP } from "../crypto/totp";3433 import type { SavedIdentity, VaultEntry } from "../shared/types";3534 3635 // WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value@@ -338,8 +337,11 @@338337     }).catch(() => {});339338   }340339 341-  if (entry.totp) {342-    const { code } = generateTOTP(entry.totp);340+  // The background sends a freshly-generated 6-digit code (totpCode), never the341+  // base32 seed (SEV-3-C). Fill the code straight in.342+  const totpCode = (entry as { totpCode?: string }).totpCode;343+  if (totpCode) {344+    const code = totpCode;343345     const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;344346     if (fillTOTPCode(totpField, code)) {345347       filledTOTPField = totpField;@@ -541,8 +543,8 @@541543           payload: { id },542544         }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;543545         const entry = entryResponse?.ok ? entryResponse.data : undefined;544-        if (!entry?.totp) return;545-        const { code } = generateTOTP(entry.totp);546+        const code = (entry as { totpCode?: string } | undefined)?.totpCode;547+        if (!code) return;546548         if (otpDigitBoxes && otpDigitBoxes.length > 1) {547549           fillOTPDigitBoxes(otpDigitBoxes, code);548550         } else {

view file

src/content/inject.ts+32 −16
@@ -11,7 +11,6 @@1111 1212 import { ACCENT_COLORS, type PasswordPolicy, type SavedIdentity } from "../shared/types";1313 import { generatePassword } from "../shared/generate";14-import { generateTOTP } from "../crypto/totp";1514 import { detectPasswordPolicy } from "./policy";1615 1716 let accentHex = "#E8956D";@@ -2206,15 +2205,20 @@22062205     cleanupHooks.push(closeTray);22072206 22082207     async function openTOTPTray() {2209-      // Fetch the entry to get the TOTP secret. The selector summary doesn't2210-      // carry it (and shouldn't — passing secrets only at fill/copy time keeps2211-      // the message-passing surface minimal per Projects.md §5.2).2212-      const resp = await chrome.runtime.sendMessage({2213-        type: "GET_ENTRY_FOR_FILL",2214-        payload: { id: entry.id },2215-      }).catch(() => null) as { ok: boolean; data?: { totp?: string } } | null;2216-      const secret = resp?.ok ? resp.data?.totp : undefined;2217-      if (!secret) return;2208+      // Fetch the current TOTP code from the background. The raw base32 seed2209+      // never enters the content script (SEV-3-C): we receive the live 6-digit2210+      // code plus the seconds remaining, count down locally, and re-fetch a2211+      // fresh code only when the 30s window rolls over.2212+      async function fetchCode(): Promise<{ code: string; remaining: number } | null> {2213+        const r = await chrome.runtime.sendMessage({2214+          type: "GET_ENTRY_FOR_FILL",2215+          payload: { id: entry.id },2216+        }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null;2217+        if (!r?.ok || !r.data?.totpCode) return null;2218+        return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 };2219+      }2220+      let current = await fetchCode();2221+      if (!current) return;22182222 22192223       trayEl = document.createElement("div");22202224       trayEl.className = "sp-tray";@@ -2251,8 +2255,8 @@22512255       copyBtn.textContent = "Copy";22522256       trayEl.appendChild(copyBtn);22532257 2254-      function refresh() {2255-        const { code, remaining } = generateTOTP(secret!);2258+      function render() {2259+        const { code, remaining } = current!;22562260         // Group as "847 291" — easier to read than "847291"22572261         codeEl.textContent = code.length === 622582262           ? `${code.slice(0, 3)} ${code.slice(3)}`@@ -2262,12 +2266,22 @@22622266         const fraction = (30 - remaining) / 30;22632267         fillC.setAttribute("stroke-dashoffset", String(62.83 * fraction));22642268       }2265-      refresh();2266-      trayInterval = setInterval(refresh, 1000);2269+      render();2270+      trayInterval = setInterval(async () => {2271+        if (!current) return;2272+        current = { code: current.code, remaining: current.remaining - 1 };2273+        // Window rolled over — pull a fresh code from the background.2274+        if (current.remaining <= 0) {2275+          const next = await fetchCode();2276+          current = next ?? { code: current.code, remaining: 30 };2277+        }2278+        render();2279+      }, 1000);22672280 22682281       copyBtn.addEventListener("click", async (e) => {22692282         e.stopPropagation();2270-        const { code } = generateTOTP(secret!);2283+        const code = current?.code;2284+        if (!code) return;22712285         let alreadyWritten = false;22722286         try {22732287           await navigator.clipboard.writeText(code);@@ -2345,8 +2359,10 @@23452359     container.appendChild(wrap);23462360 23472361     const [res, idRes] = await Promise.all([2362+      // The edit form needs the raw TOTP seed so a save doesn't wipe it — use2363+      // the trusted edit channel, not the seed-stripped GET_ENTRY_FOR_FILL.23482364       chrome.runtime.sendMessage({2349-        type: "GET_ENTRY_FOR_FILL",2365+        type: "GET_ENTRY_FOR_EDIT",23502366         payload: { id: summary.id },23512367       }).catch(() => null),23522368       chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null),

view file

src/shared/types.ts+1 −0
@@ -238,6 +238,7 @@238238   | "COPY"239239   | "CREATE_VAULT"240240   | "GET_ENTRY_FOR_FILL"241+  | "GET_ENTRY_FOR_EDIT"241242   | "CHECK_ALL_BREACHES"242243   | "GET_SETTINGS"243244   | "UPDATE_SETTINGS"

view file