7e2247778b35
feat: Add inline 2FA field picker (v3.6.47)
- time
- 2026-05-19 23:14 UTC · 5 months ago
- machine
- koh2
- parent
- 0851645934f7
↓ peach-browser-7e224777.tar.gzatlas at this save
files changed10 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 −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",5- "version": "3.6.46",5+ "version": "3.6.47",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",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",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",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": {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");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 }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 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 loginsrc/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 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;