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
↓ peach-browser-3bc26b9c.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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": {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,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");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": {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 {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),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"