7e2247778b35

feat: Add inline 2FA field picker (v3.6.47)

time
2026-05-19 23:14 UTC · 5 months ago
machine
koh2
parent
0851645934f7
10 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/background/messages.test.ts+38 −0src/background/messages.ts+19 −1src/content/detect.test.ts+13 −0src/content/detect.ts+19 −0src/content/index.ts+147 −14src/content/inject.ts+179 −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",5-  "version": "3.6.46",5+  "version": "3.6.47",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.46",4+  "version": "3.6.47",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.46",3+  "version": "3.6.47",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.46",9+      "version": "3.6.47",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.46",3+  "version": "3.6.47",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.test.ts+38 −0
@@ -426,6 +426,44 @@426426     expect(response.data).toHaveLength(1);427427   });428428 429+  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {430+    const vault = await import("./vault");431+    await vault.createVault("correct-pass-12345");432+    await vault.addEntry({433+      title: "GitHub",434+      username: "june",435+      password: "secret123",436+      url: "https://github.com",437+      totp: "JBSWY3DPEHPK3PXP",438+    });439+    await vault.addEntry({440+      title: "No 2FA",441+      username: "person",442+      password: "secret123",443+      url: "https://github.com",444+    });445+446+    const response = await dispatch({447+      type: "AUTOFILL_QUERY",448+      payload: { domain: "github.com", kind: "totp" },449+    });450+451+    expect(response).toMatchObject({452+      ok: true,453+      data: [454+        {455+          id: expect.any(String),456+          title: "GitHub",457+          username: "june",458+          url: "https://github.com",459+          hasTotp: true,460+        },461+      ],462+    });463+    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();464+    expect(response.data).toHaveLength(1);465+  });466+429467   it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {430468     const vault = await import("./vault");431469     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+19 −1
@@ -469,7 +469,7 @@469469         const { domain, includeAll, kind } = msg.payload as {470470           domain: string;471471           includeAll?: boolean;472-          kind?: "login" | "card";472+          kind?: "login" | "card" | "totp";473473         };474474         // kind="card": return saved cards as compact summaries for the475475         // inline card silver platter. Cards are not domain-scoped — every@@ -487,6 +487,24 @@487487               expMonth: e.expMonth,488488               expYear: e.expYear,489489               network: e.network,490+            })),491+          };492+        }493+        if (kind === "totp") {494+          const matches = (495+            includeAll496+              ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")497+              : vault.queryDomain(domain)498+          ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));499+          return {500+            ok: true,501+            data: matches.map((e) => ({502+              id: e.id,503+              title: e.title,504+              username: e.username,505+              email: e.email,506+              url: e.url,507+              hasTotp: true,490508             })),491509           };492510         }

view file

src/content/detect.test.ts+13 −0
@@ -9,6 +9,7 @@99   hasPaymentProcessorIframe,1010   isLoginForm,1111   isSignupForm,12+  isTOTPField,1213 } from "./detect";1314 1415 describe("content detect", () => {@@ -94,6 +95,18 @@9495 9596   it("getPageDomain falls back to the current hostname when no registrable domain exists", () => {9697     expect(getPageDomain()).toBe(window.location.hostname);98+  });99+100+  it("isTOTPField detects one-time-code fields without matching generic code words", () => {101+    const oneTimeCode = visibleInput({ autocomplete: "one-time-code" });102+    const otpName = visibleInput({ name: "otp" });103+    const verification = visibleInput({ placeholder: "Verification code" });104+    const zipCode = visibleInput({ name: "zipcode" });105+106+    expect(isTOTPField(oneTimeCode)).toBe(true);107+    expect(isTOTPField(otpName)).toBe(true);108+    expect(isTOTPField(verification)).toBe(true);109+    expect(isTOTPField(zipCode)).toBe(false);97110   });98111 });99112 

view file

src/content/detect.ts+19 −0
@@ -74,6 +74,25 @@7474   return null;7575 }7676 77+export function isTOTPField(input: HTMLInputElement): boolean {78+  if (!isVisible(input)) return false;79+  const autocomplete = input.autocomplete.toLowerCase();80+  const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();81+  if (82+    autocomplete === "one-time-code" ||83+    /\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||84+    /\b(verification|authenticator|security)\s+code\b/.test(fieldText)85+  ) {86+    return true;87+  }88+89+  const labelText = findLabelText(input);90+  return (91+    /\b(otp|totp|2fa|mfa)\b/.test(labelText) ||92+    /\b(verification|authenticator|security)\s+code\b/.test(labelText)93+  );94+}95+7796 export function isLoginForm(fields: DetectedField[]): boolean {7897   const passwords = fields.filter((f) => f.type === "password");7998   // Two or more password fields means signup/change-password, not login

view file

src/content/index.ts+147 −14
@@ -5,6 +5,7 @@55   getPageDomain,66   isLoginForm,77   isSignupForm,8+  isTOTPField,89   isVisible,910 } from "./detect";1011 import {@@ -17,6 +18,7 @@1718   showPasskeyProGate,1819   showPasskeyRegistrationOffer,1920   showRememberSiteToast,21+  showTOTPSelector,2022 } from "./inject";2123 import { detectBrowser } from "../shared/browser";2224 import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";@@ -286,20 +288,7 @@286288 function findTOTPField(): HTMLInputElement | null {287289   const inputs = document.querySelectorAll<HTMLInputElement>("input");288290   for (const input of inputs) {289-    if (!isVisible(input)) continue;290-    const autocomplete = input.autocomplete.toLowerCase();291-    const nameId = (input.name + input.id).toLowerCase();292-    const nameIdPlaceholder = nameId + input.placeholder.toLowerCase();293-    if (294-      autocomplete === "one-time-code" ||295-      /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(nameIdPlaceholder)296-    ) {297-      return input;298-    }299-    const labelEl = input.closest("label");300-    if (labelEl && /\botp\b|\btotp\b|\b2fa\b|\bmfa\b|\bcode\b/.test(labelEl.textContent?.toLowerCase() ?? "")) {301-      return input;302-    }291+    if (isTOTPField(input)) return input;303292   }304293   return null;305294 }@@ -389,7 +378,21 @@389378   return { entries: res.data ?? [], locked: false };390379 }391380 381+async function fetchTOTPEntries(382+  domain: string383+): Promise<{ entries: SelectorEntry[]; locked: boolean }> {384+  const response = await chrome.runtime.sendMessage({385+    type: "AUTOFILL_QUERY",386+    payload: { domain, kind: "totp" },387+  });388+  const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };389+  if (!res.ok && res.error === "vault_locked") {390+    return { entries: [], locked: true };391+  }392+  return { entries: res.data ?? [], locked: false };393+}392394 395+393396 function resolveAutofillFieldType(394397   el: HTMLInputElement,395398   predictedType?: FieldPrediction["type"] | null@@ -488,7 +491,70 @@488491   );489492 }490493 494+async function openTOTPSelector(anchorField: HTMLInputElement): Promise<void> {495+  if (!canOpen()) return;496+  if (currentDismiss && currentAnchorField === anchorField) return;497+  if (anchorField !== lastDismissedField) lastDismissedField = null;498+  if (anchorField === lastDismissedField) return;499+  if (wasAlreadyFilled(anchorField)) return;491500 501+  const domain = getPageDomain();502+  const result = await fetchTOTPEntries(domain);503+504+  if (result.locked) {505+    currentDismiss?.();506+    currentAnchorField = anchorField;507+    currentDismiss = showAutofillSelector(508+      anchorField,509+      [],510+      () => {},511+      {512+        domain,513+        locked: true,514+        onUnlocked: () => void openTOTPSelector(anchorField),515+        onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },516+      }517+    );518+    return;519+  }520+521+  if (result.entries.length === 0) return;522+523+  currentDismiss?.();524+  currentAnchorField = anchorField;525+  currentDismiss = showTOTPSelector(526+    anchorField,527+    result.entries,528+    async (id: string) => {529+      try {530+        const entryResponse = await chrome.runtime.sendMessage({531+          type: "GET_ENTRY_FOR_FILL",532+          payload: { id },533+        }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;534+        const entry = entryResponse?.ok ? entryResponse.data : undefined;535+        if (!entry?.totp) return;536+        const { code } = generateTOTP(entry.totp);537+        setNativeValue(anchorField, code);538+        recordFillSuccess(anchorField);539+        showToast("2FA code filled");540+      } finally {541+        currentDismiss = null;542+        currentAnchorField = null;543+      }544+    },545+    {546+      domain,547+      onDismiss: () => {548+        lastDismissedField = anchorField;549+        startDismissLock();550+        currentDismiss = null;551+        currentAnchorField = null;552+      },553+    }554+  );555+}556+557+492558 async function handleFieldInteraction(anchorField?: HTMLInputElement | null): Promise<void> {493559   const field = anchorField ?? getDeepActiveInput();494560   if (!field) return;@@ -523,6 +589,14 @@523589   pendingInteractions.add(field);524590 525591   try {592+    if (isTOTPField(field)) {593+      const formFields = detectLoginFields();594+      if (!isLoginForm(formFields) && !isSignupForm(formFields)) {595+        await openTOTPSelector(field);596+        return;597+      }598+    }599+526600     const fieldType = field.type.toLowerCase();527601 528602     const mlPromise = contentMLDetector.predictBatch([field]).then((preds) => {@@ -689,9 +763,68 @@689763   }690764 }691765 766+// ── TOTP-only fields ───────────────────────────────────────────────────────767+// After a username/password submission, many sites swap the whole form for a768+// lone "verification code" input. The credential selector intentionally gates769+// on login/signup forms, so TOTP fields need a narrow path of their own.770+const processedTOTPInputs = new WeakMap<HTMLInputElement, () => void>();771+772+function hasLoginOrSignupForm(): boolean {773+  const fields = detectLoginFields();774+  return isLoginForm(fields) || isSignupForm(fields);775+}776+777+function injectTOTPFieldIcon(field: HTMLInputElement): void {778+  if (processedTOTPInputs.has(field)) return;779+  const cleanup = injectFieldIcon(field, () => {780+    currentDismiss?.();781+    currentDismiss = null;782+    currentAnchorField = null;783+    lastDismissedField = null;784+    dismissLock = false;785+    if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }786+    clearFillStateForExplicitSummon(field);787+    field.focus({ preventScroll: true });788+    void openTOTPSelector(field);789+  });790+  processedTOTPInputs.set(field, cleanup);791+}792+793+function scanTOTPFields(): void {794+  if (hasLoginOrSignupForm()) return;795+  const inputs = document.querySelectorAll<HTMLInputElement>("input");796+  for (const input of inputs) {797+    if (!isTOTPField(input)) continue;798+    injectTOTPFieldIcon(input);799+  }800+}801+802+function setupTOTPAutofill(): void {803+  setTimeout(scanTOTPFields, 0);804+  setTimeout(scanTOTPFields, 500);805+  setTimeout(scanTOTPFields, 1500);806+807+  const debouncedScan = debounce(scanTOTPFields, 200);808+  new MutationObserver(debouncedScan).observe(document.body, {809+    childList: true,810+    subtree: true,811+    attributes: true,812+    attributeFilter: ["autocomplete", "name", "id", "placeholder", "hidden"],813+  });814+815+  document.addEventListener("focusin", (e) => {816+    const target = e.target;817+    if (!(target instanceof HTMLInputElement)) return;818+    if (hasLoginOrSignupForm()) return;819+    if (!isTOTPField(target)) return;820+    injectTOTPFieldIcon(target);821+  }, true);822+}823+692824 document.addEventListener("focusin", onFocusIn);693825 694826 setupMutationObserver();827+setupTOTPAutofill();695828 setupSaveDetection();696829 setupCardAutofill();697830 

view file

src/content/inject.ts+179 −0
@@ -2650,6 +2650,185 @@26502650   return dismiss;26512651 }26522652 2653+export function showTOTPSelector(2654+  anchorEl: HTMLInputElement,2655+  entries: SelectorEntry[],2656+  onFill: (id: string) => void,2657+  options: { domain?: string; onDismiss?: () => void } = {}2658+): () => void {2659+  const host = document.createElement("div");2660+  host.style.cssText = "position: absolute; z-index: 2147483647;";2661+  host.style.setProperty("--peach-accent", accentHex);2662+  host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));2663+  host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));2664+  host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));2665+  const shadow = host.attachShadow({ mode: "closed" });2666+2667+  const style = shadow.appendChild(document.createElement("style"));2668+  style.textContent = SELECTOR_STYLES;2669+2670+  const rect = anchorEl.getBoundingClientRect();2671+  host.style.top = `${rect.bottom + window.scrollY + 5}px`;2672+  host.style.left = `${rect.left + window.scrollX}px`;2673+  const popupWidth = Math.min(Math.max(rect.width, 300), 360);2674+  host.style.width = `${popupWidth}px`;2675+2676+  const container = shadow.appendChild(document.createElement("div"));2677+  container.className = "peach-inline-popup";2678+  container.setAttribute("data-theme", popupTheme);2679+2680+  const domain = options.domain ?? location.hostname.replace(/^www\./, "");2681+  const current = entries.filter((entry) => entry.hasTotp);2682+  let focusedIndex = current.length === 1 ? 0 : -1;2683+2684+  function updatePosition() {2685+    const r = anchorEl.getBoundingClientRect();2686+    host.style.top = `${r.bottom + window.scrollY + 5}px`;2687+    host.style.left = `${r.left + window.scrollX}px`;2688+    host.style.width = `${popupWidth}px`;2689+  }2690+2691+  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);2692+2693+  function choose(entry: SelectorEntry) {2694+    onFill(entry.id);2695+    dismiss();2696+  }2697+2698+  function render() {2699+    container.innerHTML = "";2700+2701+    const domHdr = document.createElement("div");2702+    domHdr.className = "domain-header";2703+    const headText = document.createElement("div");2704+    headText.className = "domain-header-text";2705+    const eyebrow = document.createElement("div");2706+    eyebrow.className = "domain-eyebrow";2707+    eyebrow.textContent = "2FA code";2708+    const domName = document.createElement("div");2709+    domName.className = "domain-name";2710+    domName.textContent = domain;2711+    headText.append(eyebrow, domName);2712+    domHdr.appendChild(headText);2713+    container.appendChild(domHdr);2714+2715+    if (current.length === 1) {2716+      const entry = current[0]!;2717+      const platter = document.createElement("div");2718+      platter.className = "silver-platter";2719+2720+      const title = document.createElement("div");2721+      title.className = "sp-title";2722+      title.textContent = entry.title;2723+      platter.appendChild(title);2724+2725+      const sub = document.createElement("div");2726+      sub.className = "sp-username";2727+      sub.textContent = entry.username ?? entry.email ?? entry.url ?? "";2728+      platter.appendChild(sub);2729+2730+      const fill = document.createElement("button");2731+      fill.className = "sp-fill-btn";2732+      fill.type = "button";2733+      fill.textContent = "Fill 2FA code";2734+      fill.addEventListener("click", () => choose(entry));2735+      platter.appendChild(fill);2736+2737+      container.appendChild(platter);2738+      requestAnimationFrame(() => fill.focus({ preventScroll: true }));2739+      return;2740+    }2741+2742+    const results = document.createElement("div");2743+    results.className = "results";2744+    container.appendChild(results);2745+2746+    for (let i = 0; i < current.length; i++) {2747+      const entry = current[i]!;2748+      const row = document.createElement("div");2749+      row.className = `entry${i === focusedIndex ? " focused" : ""}`;2750+      row.dataset.index = String(i);2751+      row.setAttribute("tabindex", "-1");2752+2753+      const icon = document.createElement("div");2754+      icon.className = "entry-icon";2755+      icon.innerHTML = keySvgStr();2756+      row.appendChild(icon);2757+      const info = document.createElement("div");2758+      info.className = "entry-info";2759+      const title = document.createElement("div");2760+      title.className = "entry-title";2761+      title.textContent = entry.title;2762+      const sub = document.createElement("div");2763+      sub.className = "entry-sub";2764+      sub.textContent = entry.username ?? entry.email ?? entry.url ?? "";2765+      info.append(title, sub);2766+      row.appendChild(info);2767+2768+      const badge = document.createElement("span");2769+      badge.className = "totp-badge";2770+      badge.textContent = "Fill";2771+      row.appendChild(badge);2772+      row.addEventListener("click", () => choose(entry));2773+      results.appendChild(row);2774+    }2775+  }2776+2777+  function setFocused(index: number) {2778+    focusedIndex = index;2779+    container.querySelectorAll(".entry").forEach((el, i) => {2780+      el.classList.toggle("focused", i === focusedIndex);2781+      if (i === focusedIndex) (el as HTMLElement).scrollIntoView({ block: "nearest" });2782+    });2783+  }2784+2785+  function handleKeyDown(e: KeyboardEvent) {2786+    if (e.key === "Escape" || e.key === "Tab") {2787+      e.preventDefault();2788+      dismiss(true);2789+      return;2790+    }2791+    if (current.length === 0) return;2792+    if (e.key === "Enter") {2793+      e.preventDefault();2794+      choose(current[Math.max(0, focusedIndex)]!);2795+      return;2796+    }2797+    if (current.length === 1) return;2798+    if (e.key === "ArrowDown") {2799+      e.preventDefault();2800+      setFocused(focusedIndex < current.length - 1 ? focusedIndex + 1 : 0);2801+    } else if (e.key === "ArrowUp") {2802+      e.preventDefault();2803+      setFocused(focusedIndex > 0 ? focusedIndex - 1 : current.length - 1);2804+    }2805+  }2806+2807+  let exitScheduled = false;2808+  function dismiss(userAction = false) {2809+    if (exitScheduled) return;2810+    exitScheduled = true;2811+    stopObserving();2812+    document.removeEventListener("click", outsideClick, { capture: true });2813+    document.removeEventListener("keydown", handleKeyDown);2814+    if (userAction) options.onDismiss?.();2815+    container.classList.add("is-exiting");2816+    setTimeout(() => host.remove(), 130);2817+  }2818+2819+  function outsideClick(ev: MouseEvent) {2820+    const path = ev.composedPath();2821+    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);2822+  }2823+2824+  document.addEventListener("keydown", handleKeyDown);2825+  document.body.appendChild(host);2826+  render();2827+  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);2828+2829+  return dismiss;2830+}2831+26532832 export interface CardSelectorEntry {26542833   id: string;26552834   title: string;

view file