f262c65e6083
support email-first, password-step, and all multi-step login flows (v3.6.102)
- time
- 2026-05-28 02:52 UTC · 5 months ago
- machine
- koh2
- parent
- 2b15820d0bc0
↓ peach-browser-f262c65e.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+10 −4manifest.json+7 −3package-lock.json+2 −2package.json+1 −1src/content/detect.ts+52 −0src/content/index.ts+18 −18manifest.firefox.json+10 −4
@@ -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.101",5+ "version": "3.6.102",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {@@ -26,7 +26,9 @@2626 }2727 },2828 "background": {29- "scripts": ["src/background/entry.ts"],29+ "scripts": [30+ "src/background/entry.ts"31+ ],3032 "type": "module"3133 },3234 "content_scripts": [@@ -102,8 +104,12 @@102104 },103105 "web_accessible_resources": [104106 {105- "resources": ["ggwave/*"],106- "matches": ["<all_urls>"]107+ "resources": [108+ "ggwave/*"109+ ],110+ "matches": [111+ "<all_urls>"112+ ]107113 }108114 ],109115 "icons": {manifest.json+7 −3
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.101",4+ "version": "3.6.102",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",@@ -74,8 +74,12 @@7474 },7575 "web_accessible_resources": [7676 {77- "resources": ["ggwave/*"],78- "matches": ["<all_urls>"]77+ "resources": [78+ "ggwave/*"79+ ],80+ "matches": [81+ "<all_urls>"82+ ]7983 }8084 ],8185 "icons": {package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22 "name": "peach",3- "version": "3.6.100",3+ "version": "3.6.102",44 "lockfileVersion": 3,55 "requires": true,66 "packages": {77 "": {88 "name": "peach",9- "version": "3.6.100",9+ "version": "3.6.102",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.101",3+ "version": "3.6.102",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/content/detect.ts+52 −0
@@ -104,6 +104,58 @@104104 );105105 }106106 107+// AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent.108+// ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.).109+const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;110+const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;111+112+// Email-first / identifier-first step: user enters email or username on this page;113+// the password prompt comes on the next step. Requires at least one auth-intent114+// signal (URL path, form action, or submit button text) to avoid false-positives115+// on newsletter and search forms that also have a single email field.116+export function isEmailFirstForm(fields: DetectedField[]): boolean {117+ if (!fields.some(f => f.type === "username" || f.type === "email")) return false;118+ if (fields.some(f => f.type === "password")) return false;119+120+ // Strong URL signal: /login, /signin, /auth, /sso, /account, etc.121+ if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;122+123+ // Check submit buttons for auth intent.124+ const buttons = Array.from(document.querySelectorAll<HTMLElement>(125+ 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'126+ ));127+ const hasAuthButton = buttons.some(b => {128+ const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";129+ return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);130+ });131+ if (!hasAuthButton) return false;132+133+ // Reject if the same form has name/phone/address fields — that's registration, not login.134+ const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;135+ const form = identifierEl.closest("form") ?? document.body;136+ const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {137+ if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;138+ if (!isVisible(el)) return false;139+ return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i140+ .test(el.name + el.id + el.placeholder);141+ });142+ return !hasRegistrationFields;143+}144+145+// Password-step: the email/username was entered on a previous page; this page146+// shows only a single password field. Also catches sites that render username147+// and password on separate screens without autocomplete hints.148+export function isPasswordStepForm(fields: DetectedField[]): boolean {149+ const passwords = fields.filter(f => f.type === "password");150+ return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");151+}152+153+// Single entry point used by every gate in index.ts. Covers all four patterns:154+// traditional login, signup, email-first step, and password-only step.155+export function isAnyCredentialForm(fields: DetectedField[]): boolean {156+ return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);157+}158+107159 export function isLoginForm(fields: DetectedField[]): boolean {108160 const passwords = fields.filter((f) => f.type === "password");109161 // Two or more password fields means signup/change-password, not loginsrc/content/index.ts+18 −18
@@ -3,6 +3,8 @@33 detectLoginFields,44 getDeepActiveInput,55 getPageDomain,6+ isAnyCredentialForm,7+ isEmailFirstForm,68 isLoginForm,79 isSignupForm,810 isTOTPField,@@ -302,12 +304,12 @@302304 async function handleAutofill(303305 _field: HTMLInputElement,304306 usernameEl: HTMLInputElement | null,305- passwordEl: HTMLInputElement,307+ passwordEl: HTMLInputElement | null,306308 entry: VaultEntry307309 ): Promise<HTMLInputElement | null> {308310 if (!entry.username && !entry.password) return null;309311 const pageDomain = getPageDomain();310- const form = passwordEl.closest("form") as HTMLFormElement | null;312+ const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;311313 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);312314 let filledTOTPField: HTMLInputElement | null = null;313315 @@ -337,7 +339,7 @@337339 338340 if (entry.totp) {339341 const { code } = generateTOTP(entry.totp);340- const totpField = findSameLoginTOTPField(passwordEl);342+ const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;341343 if (fillTOTPCode(totpField, code)) {342344 filledTOTPField = totpField;343345 }@@ -420,7 +422,7 @@420422 async function openAutofillSelector(421423 anchorField: HTMLInputElement,422424 usernameEl: HTMLInputElement | null,423- passwordEl: HTMLInputElement,425+ passwordEl: HTMLInputElement | null,424426 domainEntries: SelectorEntry[],425427 searchableEntries: SelectorEntry[],426428 domain: string@@ -438,7 +440,7 @@438440 if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;439441 } catch { /* silent fallback */ }440442 441- const form = passwordEl.closest("form") ?? document.body;443+ const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;442444 const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));443445 confirmEl = allPwInputs.find(444446 (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)@@ -482,7 +484,7 @@482484 isSignup,483485 savedIdentities,484486 onFillFields: (username, password) => {485- const form = passwordEl.closest("form") as HTMLFormElement | null;487+ const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;486488 fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());487489 if (password && confirmEl) setNativeValue(confirmEl, password);488490 recordFillSuccess(anchorField, usernameEl, passwordEl);@@ -629,9 +631,7 @@629631 }630632 631633 let fields = detectLoginFields();632- const loginForm = isLoginForm(fields);633- const signupForm = isSignupForm(fields);634- if (!loginForm && !signupForm) {634+ if (!isAnyCredentialForm(fields)) {635635 return;636636 }637637 @@ -667,8 +667,9 @@667667 const userField = fields.find((f) => f.type === "username" || f.type === "email");668668 if (userField) usernameEl = userField.el;669669 const pwField = fields.find((f) => f.type === "password");670- if (!pwField) return;671- const passwordEl = pwField.el;670+ // For email-first flows there is no password field on this step — that is expected.671+ if (!pwField && !isEmailFirstForm(fields)) return;672+ const passwordEl = pwField?.el ?? null;672673 673674 await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);674675 } finally {@@ -693,9 +694,9 @@693694 ): void {694695 if (processedInputs.has(el)) return;695696 if (!resolveAutofillFieldType(el, detectedType)) return;696- // Only inject icon when a real login/signup form is present on the page697+ // Only inject icon when a credential form is present on the page697698 const fields = contextFields ?? detectLoginFields();698- if (!isLoginForm(fields) && !isSignupForm(fields)) return;699+ if (!isAnyCredentialForm(fields)) return;699700 700701 const cleanup = injectFieldIcon(el, () => {701702 currentDismiss?.();@@ -712,8 +713,7 @@712713 713714 const debouncedReattach = debounce(() => {714715 const allFields = detectLoginFields();715- // Don't inject anything if there's no real login/signup form on this page716- if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;716+ if (!isAnyCredentialForm(allFields)) return;717717 const newFields = allFields.filter(718718 (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)719719 );@@ -737,7 +737,7 @@737737 738738 function initialScan(aggressionLevel = 0, isFinalPass = false) {739739 const allFields = detectLoginFields();740- const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);740+ const hasKnownForm = isAnyCredentialForm(allFields);741741 742742 if (aggressionLevel === 0 && !hasKnownForm) return;743743 @@ -779,7 +779,7 @@779779 780780 function hasLoginOrSignupForm(): boolean {781781 const fields = detectLoginFields();782- return isLoginForm(fields) || isSignupForm(fields);782+ return isAnyCredentialForm(fields);783783 }784784 785785 function injectTOTPFieldIcon(field: HTMLInputElement): void {@@ -1004,7 +1004,7 @@10041004 10051005 const activeInput = getDeepActiveInput();10061006 const fields = detectLoginFields();1007- const hasForm = isLoginForm(fields) || isSignupForm(fields);1007+ const hasForm = isAnyCredentialForm(fields);10081008 10091009 if (hasForm) {10101010 // Prefer the currently focused field if it's a login field, otherwise