4021a2198f72

fix: Fill same-form 2FA with login credentials (v3.6.48)

time
2026-05-19 23:39 UTC · 5 months ago
machine
koh2
parent
b68ab276fa94
9 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/content/content.adversarial.test.ts+16 −0src/content/detect.test.ts+9 −0src/content/detect.ts+12 −1src/content/fill.ts+10 −0src/content/index.ts+20 −19
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",5-  "version": "3.6.47",5+  "version": "3.6.48",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",4-  "version": "3.6.47",4+  "version": "3.6.48",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.47",3+  "version": "3.6.48",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.47",9+      "version": "3.6.48",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

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

view file

src/content/content.adversarial.test.ts+16 −0
@@ -27,6 +27,7 @@2727   detectCardNetwork,2828   extractLast4,2929   fillFields,30+  fillTOTPCode,3031   formatCardNumber,3132   passesLuhn,3233   setNativeValue,@@ -373,6 +374,21 @@373374       fillFields(username, password, "", "pw");374375       expect(username.value).toBe("preexisting");375376       expect(password.value).toBe("pw");377+    });378+379+    it("fillTOTPCode fills an empty visible 2FA field", () => {380+      const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });381+      const filled = fillTOTPCode(totp, "123456");382+      expect(filled).toBe(true);383+      expect(totp.value).toBe("123456");384+    });385+386+    it("fillTOTPCode does not overwrite an existing 2FA value", () => {387+      const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });388+      totp.value = "654321";389+      const filled = fillTOTPCode(totp, "123456");390+      expect(filled).toBe(false);391+      expect(totp.value).toBe("654321");376392     });377393   });378394 

view file

src/content/detect.test.ts+9 −0
@@ -108,6 +108,15 @@108108     expect(isTOTPField(verification)).toBe(true);109109     expect(isTOTPField(zipCode)).toBe(false);110110   });111+112+  it("isTOTPField detects an adjacent 2FA label", () => {113+    const label = document.createElement("label");114+    label.textContent = "2FA Code";115+    document.body.appendChild(label);116+    const input = visibleInput({ placeholder: "If enabled" });117+118+    expect(isTOTPField(input)).toBe(true);119+  });111120 });112121 113122 describe("card form detection", () => {

view file

src/content/detect.ts+12 −1
@@ -40,7 +40,18 @@4040     if (label) return label.textContent?.toLowerCase() ?? "";4141   }4242   const wrappingLabel = field.closest("label");43-  return wrappingLabel?.textContent?.toLowerCase() ?? "";43+  if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();44+45+  let node: Element | null = field;46+  for (let depth = 0; node && depth < 3; depth++) {47+    const previous = node.previousElementSibling;48+    const text = previous?.textContent?.trim().toLowerCase() ?? "";49+    if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {50+      return text;51+    }52+    node = node.parentElement;53+  }54+  return "";4455 }4556 4657 export function querySelectorAllDeep<T extends Element>(

view file

src/content/fill.ts+10 −0
@@ -38,6 +38,16 @@3838   }3939 }4040 41+export function fillTOTPCode(42+  totpEl: HTMLInputElement | null,43+  code: string44+): boolean {45+  if (!totpEl || !code || !isVisible(totpEl)) return false;46+  if (totpEl.value.trim() !== "") return false;47+  setNativeValue(totpEl, code);48+  return true;49+}50+4151 // React tracks input values via a synthetic event system that does not see4252 // direct `.value =` assignment. We must call the native HTMLInputElement4353 // value setter (pulled off the prototype) and then dispatch the real input

view file

src/content/index.ts+20 −19
@@ -22,7 +22,7 @@2222 } from "./inject";2323 import { detectBrowser } from "../shared/browser";2424 import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";25-import { fillCardForm, fillFields, setNativeValue } from "./fill";25+import { fillCardForm, fillFields, fillTOTPCode, setNativeValue } from "./fill";2626 import { setupSaveDetection } from "./save";2727 import { contentMLDetector } from "./content-ml-detector";2828 import { detectPasswordPolicy } from "./policy";@@ -285,21 +285,17 @@285285   });286286 }287287 288-function findTOTPField(): HTMLInputElement | null {289-  const inputs = document.querySelectorAll<HTMLInputElement>("input");288+function findTOTPField(root: Document | Element = document): HTMLInputElement | null {289+  const inputs = root.querySelectorAll<HTMLInputElement>("input");290290   for (const input of inputs) {291291     if (isTOTPField(input)) return input;292292   }293293   return null;294294 }295295 296-async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {297-  await new Promise((r) => setTimeout(r, 400));298-  if (totpField.value === "" || totpField.value.trim() === "") {299-    const { code } = generateTOTP(totpSecret);300-    setNativeValue(totpField, code);301-    showToast("TOTP autofilled");302-  }296+function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {297+  const form = passwordEl.closest("form");298+  return findTOTPField(form ?? document);303299 }304300 305301 @@ -308,11 +304,12 @@308304   usernameEl: HTMLInputElement | null,309305   passwordEl: HTMLInputElement,310306   entry: VaultEntry311-): Promise<void> {312-  if (!entry.username && !entry.password) return;307+): Promise<HTMLInputElement | null> {308+  if (!entry.username && !entry.password) return null;313309   const pageDomain = getPageDomain();314310   const form = passwordEl.closest("form") as HTMLFormElement | null;315311   fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);312+  let filledTOTPField: HTMLInputElement | null = null;316313 317314   if (entry.canary) {318315     chrome.runtime.sendMessage({@@ -340,6 +337,10 @@340337 341338   if (entry.totp) {342339     const { code } = generateTOTP(entry.totp);340+    const totpField = findSameLoginTOTPField(passwordEl);341+    if (fillTOTPCode(totpField, code)) {342+      filledTOTPField = totpField;343+    }343344     let alreadyWritten = false;344345     try {345346       await navigator.clipboard.writeText(code);@@ -351,16 +352,16 @@351352       type: "COPY",352353       payload: { text: code, label: "TOTP", alreadyWritten },353354     });354-    if (alreadyWritten || (response as { ok?: boolean }).ok) {355+    if (filledTOTPField) {356+      showToast("2FA code filled");357+    } else if (alreadyWritten || (response as { ok?: boolean }).ok) {355358       showToast("TOTP copied · " + code);356359     } else {357360       showToast("Could not copy TOTP — clipboard blocked");358361     }359-    const totpField = findTOTPField();360-    if (totpField) {361-      autofillTOTP(totpField, entry.totp);362-    }363362   }363+364+  return filledTOTPField;364365 }365366 366367 async function fetchAutofillEntries(@@ -458,8 +459,8 @@458459       });459460       const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;460461       if (entry) {461-        await handleAutofill(anchorField, usernameEl, passwordEl, entry);462-        recordFillSuccess(anchorField, usernameEl, passwordEl);462+        const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry);463+        recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);463464         // Offer to remember the current site for this entry only if none of464465         // its known domains (primary + additional) match — otherwise the465466         // user already mapped it and the prompt would be noise.

view file