From a90a5cba08fdf6c0ceb95101c275108a152a3aed Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 12 Jun 2024 07:35:37 +0200 Subject: Merging upstream version 127.0. Signed-off-by: Daniel Baumann --- .../passwordmgr/LoginAutoComplete.sys.mjs | 254 +--------------- toolkit/components/passwordmgr/LoginHelper.sys.mjs | 105 ++++++- .../passwordmgr/LoginManagerChild.sys.mjs | 338 ++++++++++++++++----- .../passwordmgr/LoginManagerParent.sys.mjs | 50 ++- toolkit/components/passwordmgr/moz.build | 1 - .../passwordmgr/nsILoginAutoCompleteSearch.idl | 31 -- ...t_autocomplete_password_generation_confirm.html | 26 +- .../test/mochitest/test_autofocus_js.html | 6 +- .../test/unit/test_isProbablyANewPasswordField.js | 13 +- 9 files changed, 431 insertions(+), 393 deletions(-) delete mode 100644 toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl (limited to 'toolkit/components/passwordmgr') diff --git a/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs index 187caf9d62..a7ea1bfa2c 100644 --- a/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs +++ b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * nsIAutoCompleteResult and nsILoginAutoCompleteSearch implementations for saved logins. + * nsIAutoCompleteResult implementations for saved logins. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; @@ -15,11 +15,7 @@ import { const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", - LoginFormFactory: "resource://gre/modules/LoginFormFactory.sys.mjs", LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", - LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", - NewPasswordModel: "resource://gre/modules/NewPasswordModel.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( lazy, @@ -335,6 +331,8 @@ export class LoginAutoCompleteResult { // The footer comes last if it's enabled if (isFooterEnabled()) { + // TODO: This would be removed once autofill is triggered from the parent. + gAutoCompleteListener.init(); if (autocompleteItems) { this.#rows.push( ...autocompleteItems.map( @@ -472,252 +470,6 @@ export class LoginAutoCompleteResult { } } -export class LoginAutoComplete { - // HTMLInputElement to number, the element's new-password heuristic confidence score - #cachedNewPasswordScore = new WeakMap(); - #autoCompleteLookupPromise = null; - classID = Components.ID("{2bdac17c-53f1-4896-a521-682ccdeef3a8}"); - QueryInterface = ChromeUtils.generateQI(["nsILoginAutoCompleteSearch"]); - - /** - * Yuck. This is called directly by satchel: - * nsFormFillController::StartSearch() - * [toolkit/components/satchel/nsFormFillController.cpp] - * - * We really ought to have a simple way for code to register an - * auto-complete provider, and not have satchel calling pwmgr directly. - * - * @param {string} aSearchString The value typed in the field. - * @param {nsIAutoCompleteResult} aPreviousResult - * @param {HTMLInputElement} aElement - * @param {nsIFormFillCompleteObserver} aCallback - */ - startSearch(aSearchString, aPreviousResult, aElement, aCallback) { - let { isNullPrincipal } = aElement.nodePrincipal; - if ( - aElement.nodePrincipal.schemeIs("about") || - aElement.nodePrincipal.isSystemPrincipal - ) { - // Don't show autocomplete results for about: pages. - // XXX: Don't we need to call the callback here? - return; - } - - let searchStartTimeMS = Services.telemetry.msSystemNow(); - - // Show the insecure login warning in the passwords field on null principal documents. - // Avoid loading InsecurePasswordUtils.sys.mjs in a sandboxed document (e.g. an ad. frame) if we - // already know it has a null principal and will therefore get the insecure autocomplete - // treatment. - // InsecurePasswordUtils doesn't handle the null principal case as not secure because we don't - // want the same treatment: - // * The web console warnings will be confusing (as they're primarily about http:) and not very - // useful if the developer intentionally sandboxed the document. - // * The site identity insecure field warning would require LoginManagerChild being loaded and - // listening to some of the DOM events we're ignoring in null principal documents. For memory - // reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top- - // document is sandboxing a document, it probably doesn't want that sandboxed document to be - // able to affect the identity icon in the address bar by adding a password field. - let form = lazy.LoginFormFactory.createFromField(aElement); - let isSecure = - !isNullPrincipal && lazy.InsecurePasswordUtils.isFormSecure(form); - let { hasBeenTypePassword } = aElement; - let hostname = aElement.ownerDocument.documentURIObject.host; - let formOrigin = lazy.LoginHelper.getLoginOrigin( - aElement.ownerDocument.documentURI - ); - let loginManagerActor = lazy.LoginManagerChild.forWindow( - aElement.ownerGlobal - ); - let completeSearch = async autoCompleteLookupPromise => { - // Assign to the member synchronously before awaiting the Promise. - this.#autoCompleteLookupPromise = autoCompleteLookupPromise; - - let { - generatedPassword, - importable, - logins, - autocompleteItems, - willAutoSaveGeneratedPassword, - } = await autoCompleteLookupPromise; - - // If the search was canceled before we got our - // results, don't bother reporting them. - // N.B. This check must occur after the `await` above for it to be - // effective. - if (this.#autoCompleteLookupPromise !== autoCompleteLookupPromise) { - lazy.log.debug("Ignoring result from previous search."); - return; - } - - let telemetryEventData = { - acFieldName: aElement.getAutocompleteInfo().fieldName, - hadPrevious: !!aPreviousResult, - typeWasPassword: aElement.hasBeenTypePassword, - fieldType: aElement.type, - searchStartTimeMS, - stringLength: aSearchString.length, - }; - - this.#autoCompleteLookupPromise = null; - let results = new LoginAutoCompleteResult( - aSearchString, - logins, - autocompleteItems, - formOrigin, - { - generatedPassword, - willAutoSaveGeneratedPassword, - importable, - actor: loginManagerActor, - isSecure, - hasBeenTypePassword, - hostname, - telemetryEventData, - } - ); - aCallback.onSearchCompletion(results); - }; - - if (isNullPrincipal) { - // Don't search login storage when the field has a null principal as we don't want to fill - // logins for the `location` in this case. - completeSearch(Promise.resolve({ logins: [] })); - return; - } - - if ( - hasBeenTypePassword && - aSearchString && - !loginManagerActor.isPasswordGenerationForcedOn(aElement) - ) { - // Return empty result on password fields with password already filled, - // unless password generation was forced. - completeSearch(Promise.resolve({ logins: [] })); - return; - } - - if (!lazy.LoginHelper.enabled) { - completeSearch(Promise.resolve({ logins: [] })); - return; - } - - let previousResult; - if (aPreviousResult) { - previousResult = { - searchString: aPreviousResult.searchString, - logins: lazy.LoginHelper.loginsToVanillaObjects( - aPreviousResult.wrappedJSObject.logins - ), - }; - } else { - previousResult = null; - } - - let acLookupPromise = this.#requestAutoCompleteResultsFromParent({ - searchString: aSearchString, - previousResult, - inputElement: aElement, - form, - hasBeenTypePassword, - }); - completeSearch(acLookupPromise).catch(lazy.log.error.bind(lazy.log)); - } - - stopSearch() { - this.#autoCompleteLookupPromise = null; - } - - async #requestAutoCompleteResultsFromParent({ - searchString, - previousResult, - inputElement, - form, - hasBeenTypePassword, - }) { - let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form); - let autocompleteInfo = inputElement.getAutocompleteInfo(); - - let loginManagerActor = lazy.LoginManagerChild.forWindow( - inputElement.ownerGlobal - ); - let forcePasswordGeneration = false; - let isProbablyANewPasswordField = false; - if (hasBeenTypePassword) { - forcePasswordGeneration = - loginManagerActor.isPasswordGenerationForcedOn(inputElement); - // Run the Fathom model only if the password field does not have the - // autocomplete="new-password" attribute. - isProbablyANewPasswordField = - autocompleteInfo.fieldName == "new-password" || - this.isProbablyANewPasswordField(inputElement); - } - const scenario = loginManagerActor.getScenario(inputElement); - - if (lazy.LoginHelper.showAutoCompleteFooter) { - gAutoCompleteListener.init(); - } - - lazy.log.debug("LoginAutoComplete search:", { - forcePasswordGeneration, - hasBeenTypePassword, - isProbablyANewPasswordField, - searchStringLength: searchString.length, - }); - - const result = await loginManagerActor.sendQuery( - "PasswordManager:autoCompleteLogins", - { - actionOrigin, - searchString, - previousResult, - forcePasswordGeneration, - hasBeenTypePassword, - isProbablyANewPasswordField, - scenarioName: scenario?.constructor.name, - inputMaxLength: inputElement.maxLength, - isWebAuthn: this.#isWebAuthnCredentials(autocompleteInfo), - } - ); - - return { - generatedPassword: result.generatedPassword, - importable: result.importable, - autocompleteItems: result.autocompleteItems, - logins: lazy.LoginHelper.vanillaObjectsToLogins(result.logins), - willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword, - }; - } - - isProbablyANewPasswordField(inputElement) { - const threshold = lazy.LoginHelper.generationConfidenceThreshold; - if (threshold == -1) { - // Fathom is disabled - return false; - } - - let score = this.#cachedNewPasswordScore.get(inputElement); - if (score) { - return score >= threshold; - } - - const { rules, type } = lazy.NewPasswordModel; - const results = rules.against(inputElement); - score = results.get(inputElement).scoreFor(type); - this.#cachedNewPasswordScore.set(inputElement, score); - return score >= threshold; - } - - /** - * @param {string} autocompleteInfo - * @returns whether the non-autofill credential type (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#non-autofill-credential-type) - * of the input field is "webauthn" - */ - #isWebAuthnCredentials(autocompleteInfo) { - return autocompleteInfo.credentialType == "webauthn"; - } -} - let gAutoCompleteListener = { added: false, diff --git a/toolkit/components/passwordmgr/LoginHelper.sys.mjs b/toolkit/components/passwordmgr/LoginHelper.sys.mjs index 5626312f17..615ac79449 100644 --- a/toolkit/components/passwordmgr/LoginHelper.sys.mjs +++ b/toolkit/components/passwordmgr/LoginHelper.sys.mjs @@ -18,6 +18,13 @@ ChromeUtils.defineESModuleGetters(lazy, { OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", }); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "Crypto", + "@mozilla.org/login-manager/crypto/SDR;1", + "nsILoginManagerCrypto" +); + export class ParentAutocompleteOption { image; title; @@ -366,7 +373,7 @@ class ImportRowProcessor { return this.summary; } } - +const OS_AUTH_FOR_PASSWORDS_PREF = "signon.management.page.os-auth.optout"; /** * Contains functions shared by different Login Manager components. */ @@ -394,6 +401,7 @@ export const LoginHelper = { testOnlyUserHasInteractedWithDocument: null, userInputRequiredToCapture: null, captureInputChanges: null, + OS_AUTH_FOR_PASSWORDS_PREF, init() { // Watch for pref changes to update cached pref values. @@ -1582,6 +1590,90 @@ export const LoginHelper = { return token.hasPassword; }, + /** + * Get the decrypted value for a string pref. + * + * @param {string} prefName -> The pref whose value is needed. + * @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set. + * @returns {string} + */ + getSecurePref(prefName, safeDefaultValue) { + try { + const encryptedValue = Services.prefs.getStringPref(prefName, ""); + return encryptedValue === "" + ? safeDefaultValue + : lazy.Crypto.decrypt(encryptedValue); + } catch { + return safeDefaultValue; + } + }, + + /** + * Set the pref to the encrypted form of the value. + * + * @param {string} prefName -> The pref whose value is to be set. + * @param {string} value -> The value to be set in its encrypted form. + */ + setSecurePref(prefName, value) { + if (value) { + const encryptedValue = lazy.Crypto.encrypt(value); + Services.prefs.setStringPref(prefName, encryptedValue); + } else { + Services.prefs.clearUserPref(prefName); + } + }, + + /** + * Get whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The name of the pref (creditcards or addresses) + * @returns {boolean} + */ + getOSAuthEnabled(prefName) { + return ( + lazy.OSKeyStore.canReauth() && + this.getSecurePref(prefName, "") !== "opt out" + ); + }, + + /** + * Set whether the OSAuth is enabled or not. + * + * @param {string} prefName -> The pref to encrypt. + * @param {boolean} enable -> Whether the pref is to be enabled. + */ + setOSAuthEnabled(prefName, enable) { + this.setSecurePref(prefName, enable ? null : "opt out"); + }, + + async verifyUserOSAuth( + prefName, + promptMessage, + captionDialog = "", + parentWindow = null, + generateKeyIfNotAvailable = true + ) { + if (!this.getOSAuthEnabled(prefName)) { + promptMessage = false; + } + try { + return ( + await lazy.OSKeyStore.ensureLoggedIn( + promptMessage, + captionDialog, + parentWindow, + generateKeyIfNotAvailable + ) + ).authenticated; + } catch (ex) { + // Since Win throws an exception whereas Mac resolves to false upon cancelling. + if (ex.result !== Cr.NS_ERROR_FAILURE) { + throw ex; + } + } + return false; + }, + /** * Shows the Primary Password prompt if enabled, or the * OS auth dialog otherwise. @@ -1637,18 +1729,21 @@ export const LoginHelper = { } // Use the OS auth dialog if there is no primary password if (!token.hasPassword && OSReauthEnabled) { - let result = await lazy.OSKeyStore.ensureLoggedIn( + let isAuthorized = await this.verifyUserOSAuth( + OS_AUTH_FOR_PASSWORDS_PREF, messageText, captionText, browser.ownerGlobal, false ); - isAuthorized = result.authenticated; + let value = lazy.OSKeyStore.canReauth() + ? "success" + : "success_unsupported_platform"; + telemetryEvent = { object: "os_auth", method: "reauthenticate", - value: result.auth_details, - extra: result.auth_details_extra, + value: isAuthorized ? value : "fail", }; return { isAuthorized, diff --git a/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs b/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs index 66f45416f8..5433083ede 100644 --- a/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs +++ b/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs @@ -51,10 +51,12 @@ ChromeUtils.defineESModuleGetters(lazy, { FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs", FORM_SUBMISSION_REASON: "resource://gre/actors/FormHandlerChild.sys.mjs", InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", + LoginAutoCompleteResult: "resource://gre/modules/LoginAutoComplete.sys.mjs", LoginFormFactory: "resource://gre/modules/LoginFormFactory.sys.mjs", LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", LoginRecipesContent: "resource://gre/modules/LoginRecipes.sys.mjs", LoginManagerTelemetry: "resource://gre/modules/LoginManagerTelemetry.sys.mjs", + NewPasswordModel: "resource://gre/modules/NewPasswordModel.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( @@ -157,40 +159,6 @@ const observer = { loginManagerChild()._onNavigation(window.document); }, - // nsIObserver - observe(subject, topic, _data) { - switch (topic) { - case "autocomplete-did-enter-text": { - let input = subject.QueryInterface(Ci.nsIAutoCompleteInput); - let { selectedIndex } = input.popup; - if (selectedIndex < 0 || selectedIndex >= input.controller.matchCount) { - break; - } - - let { focusedInput } = lazy.gFormFillService; - if (focusedInput.nodePrincipal.isNullPrincipal) { - // If we have a null principal then prevent any more password manager code from running and - // incorrectly using the document `location`. - return; - } - - let window = focusedInput.ownerGlobal; - let loginManagerChild = LoginManagerChild.forWindow(window); - - let style = input.controller.getStyleAt(selectedIndex); - if (style == "login" || style == "loginWithOrigin") { - let details = JSON.parse( - input.controller.getCommentAt(selectedIndex) - ); - loginManagerChild.onFieldAutoComplete(focusedInput, details.guid); - } else if (style == "generatedPassword") { - loginManagerChild._filledWithGeneratedPassword(focusedInput); - } - break; - } - } - }, - // nsIDOMEventListener handleEvent(aEvent) { if (!aEvent.isTrusted) { @@ -435,9 +403,6 @@ const observer = { }, }; -// Add this observer once for the process. -Services.obs.addObserver(observer, "autocomplete-did-enter-text"); - /** * Form scenario defines what can be done with form. */ @@ -812,7 +777,9 @@ export class LoginFormState { // The login manager is responsible for fields with the "webauthn" credential type. let acCredentialType = focusedField.getAutocompleteInfo()?.credentialType; if (acCredentialType == "webauthn") { - lazy.gFormFillService.markAsLoginManagerField(focusedField); + const actor = + focusedField.ownerGlobal.windowGlobalChild.getActor("LoginManager"); + actor.markAsAutoCompletableField(focusedField); } lazy.log("Opening the autocomplete popup."); @@ -1525,6 +1492,16 @@ export class LoginManagerChild extends JSWindowActorChild { this.notifyObserversOfFormProcessed(msg.data.formid); break; } + case "PasswordManager:fillFields": { + const login = lazy.LoginHelper.vanillaObjectToLogin(msg.data); + this.fillFields(login); + break; + } + case "PasswordManager:fillGeneratedPassword": { + const { focusedInput } = lazy.gFormFillService; + this.filledWithGeneratedPassword(focusedInput); + break; + } } return undefined; @@ -2174,7 +2151,7 @@ export class LoginManagerChild extends JSWindowActorChild { /** * A username or password was autocompleted into a field. */ - onFieldAutoComplete(acInputField, loginGUID) { + onFieldAutoComplete(acInputField, login) { if (!lazy.LoginHelper.enabled) { return; } @@ -2189,7 +2166,7 @@ export class LoginManagerChild extends JSWindowActorChild { } if (lazy.LoginHelper.isUsernameFieldType(acInputField)) { - this.onUsernameAutocompleted(acInputField, loginGUID); + this.onUsernameAutocompleted(acInputField, [login]); } else if (acInputField.hasBeenTypePassword) { // Ensure the field gets re-masked and edits don't overwrite the generated // password in case a generated password was filled into it previously. @@ -2203,7 +2180,7 @@ export class LoginManagerChild extends JSWindowActorChild { * A username field was filled or tabbed away from so try fill in the * associated password in the password field. */ - onUsernameAutocompleted(acInputField, loginGUID = null) { + async onUsernameAutocompleted(acInputField, loginsFound = null) { lazy.log(`Autocompleting input field with name: ${acInputField.name}`); let acForm = lazy.LoginFormFactory.createFromField(acInputField); @@ -2219,43 +2196,49 @@ export class LoginManagerChild extends JSWindowActorChild { const docState = this.stateForDocument(acInputField.ownerDocument); let { usernameField, newPasswordField: passwordField } = docState._getFormFields(acForm, false, recipes); - if (usernameField == acInputField) { - // Fill the form when a password field is present. - if (passwordField) { - this._getLoginDataFromParent(acForm, { - guid: loginGUID, - showPrimaryPassword: false, - }) - .then(({ form, loginsFound, recipes }) => { - if (!loginGUID) { - // not an explicit autocomplete menu selection, filter for exact matches only - loginsFound = this._filterForExactFormOriginLogins( - loginsFound, - acForm - ); - // filter the list for exact matches with the username - // NOTE: this could be an empty string which is a valid username - let searchString = usernameField.value.toLowerCase(); - loginsFound = loginsFound.filter( - l => l.username.toLowerCase() == searchString - ); - } - - this._fillForm(form, loginsFound, recipes, { - autofillForm: true, - clobberPassword: true, - userTriggered: true, - }); - }) - .catch(console.error); - // Use `loginGUID !== null` to distinguish whether this is called when the - // field is filled or tabbed away from. For the latter, don't highlight the field. - } else if (loginGUID !== null) { + // Ignore the event, it's for some input we don't care about. + if (usernameField != acInputField) { + return; + } + + if (!passwordField) { + // Use `loginsFound !== null` to distinguish whether this is called when the + // field is filled or tabbed away from. For the latter, don't highlight the field. + if (loginsFound !== null) { LoginFormState._highlightFilledField(usernameField); } - } else { - // Ignore the event, it's for some input we don't care about. + return; + } + + // Fill the form when a password field is present. + if (!loginsFound) { + const loginData = await this._getLoginDataFromParent(acForm, { + showPrimaryPassword: false, + }).catch(console.error); + + if (!loginData?.loginsFound.length) { + return; + } + + // not an explicit autocomplete menu selection, filter for exact matches only + loginsFound = this._filterForExactFormOriginLogins( + loginData.loginsFound, + acForm + ); + // filter the list for exact matches with the username + // NOTE: this could be an empty string which is a valid username + const searchString = usernameField.value.toLowerCase(); + loginsFound = loginsFound.filter( + l => l.username.toLowerCase() == searchString + ); + recipes = loginData.recipes; } + + this._fillForm(acForm, loginsFound, recipes, { + autofillForm: true, + clobberPassword: true, + userTriggered: true, + }); } /** @@ -2399,7 +2382,7 @@ export class LoginManagerChild extends JSWindowActorChild { }; if (fields.usernameField) { - lazy.gFormFillService.markAsLoginManagerField(fields.usernameField); + this.markAsAutoCompletableField(fields.usernameField); } // It's possible the field triggering this message isn't one of those found by _getFormFields' heuristics @@ -2639,7 +2622,7 @@ export class LoginManagerChild extends JSWindowActorChild { * field is handled accordingly. * @param {HTMLInputElement} passwordField */ - _filledWithGeneratedPassword(passwordField) { + filledWithGeneratedPassword(passwordField) { LoginFormState._highlightFilledField(passwordField); this._passwordEditedOrGenerated(passwordField, { triggeredByFillingGenerated: true, @@ -2821,7 +2804,7 @@ export class LoginManagerChild extends JSWindowActorChild { if (scenario) { docState.setScenario(form.rootElement, scenario); - lazy.gFormFillService.markAsLoginManagerField(usernameField); + this.markAsAutoCompletableField(usernameField); } } @@ -2868,7 +2851,7 @@ export class LoginManagerChild extends JSWindowActorChild { // We would also need this attached to show the insecure login // warning, regardless of saved login. if (usernameField) { - lazy.gFormFillService.markAsLoginManagerField(usernameField); + this.markAsAutoCompletableField(usernameField); usernameField.addEventListener("keydown", observer); } @@ -3116,7 +3099,7 @@ export class LoginManagerChild extends JSWindowActorChild { } if (style === "generatedPassword") { - this._filledWithGeneratedPassword(passwordField); + this.filledWithGeneratedPassword(passwordField); } lazy.log("_fillForm succeeded"); @@ -3173,4 +3156,199 @@ export class LoginManagerChild extends JSWindowActorChild { const docState = this.stateForDocument(inputElement.ownerDocument); return docState.getScenario(inputElement); } + + #interestedInputs = []; + + markAsAutoCompletableField(input) { + this.#interestedInputs.push(input); + this.manager + .getActor("AutoComplete") + ?.markAsAutoCompletableField(input, this); + } + + get actorName() { + return "LoginManager"; + } + + /** + * Get the search options when searching for autocomplete entries in the parent + * + * @param {HTMLInputElement} input - The input element to search for autocompelte entries + * @returns {object} the search options for the input + */ + getAutoCompleteSearchOption(input, searchString) { + const form = lazy.LoginFormFactory.createFromField(input); + const formOrigin = lazy.LoginHelper.getLoginOrigin( + input.ownerDocument.documentURI + ); + const actionOrigin = lazy.LoginHelper.getFormActionOrigin(form); + const autocompleteInfo = input.getAutocompleteInfo(); + const hasBeenTypePassword = input.hasBeenTypePassword; + + let forcePasswordGeneration = false; + let isProbablyANewPasswordField = false; + if (hasBeenTypePassword) { + forcePasswordGeneration = this.isPasswordGenerationForcedOn(input); + // Run the Fathom model only if the password field does not have the + // autocomplete="new-password" attribute. + isProbablyANewPasswordField = + autocompleteInfo.fieldName == "new-password" || + this.isProbablyANewPasswordField(input); + } + + const scenarioName = lazy.FormScenarios.detect({ input }).signUpForm + ? "SignUpFormScenario" + : ""; + + const r = { + formOrigin, + actionOrigin, + searchString, + forcePasswordGeneration, + hasBeenTypePassword, + isProbablyANewPasswordField, + scenarioName, + inputMaxLength: input.maxLength, + isWebAuthn: this.#isWebAuthnCredentials(autocompleteInfo), + }; + return r; + } + + #searchStartTimeMS = null; + + /** + * Ask the provider whether it might have autocomplete entry to show + * for the given input. + * + * @param {HTMLInputElement} input - The input element to search for autocompelte entries + * @returns {boolean} true if we shold search for autocomplete entries + */ + shouldSearchForAutoComplete(input, searchString) { + this.#searchStartTimeMS = Services.telemetry.msSystemNow(); + + // Don't search login storage when the field has a null principal as we don't want to fill + // logins for the `location` in this case. + if (input.nodePrincipal.isNullPrincipal) { + return false; + } + + // Return empty result on password fields with password already filled, + // unless password generation was forced. + if ( + input.hasBeenTypePassword && + searchString && + !this.isPasswordGenerationForcedOn(input) + ) { + return false; + } + + if (!lazy.LoginHelper.enabled) { + return false; + } + + return true; + } + + /** + * Convert the search result to autocomplete results + * + * @param {string} searchString - The string to search for + * @param {HTMLInputElement} input - The input element to search for autocompelte entries + * @param {Array} records - autocomplete records + * @returns {AutocompleteResult} + */ + searchResultToAutoCompleteResult(searchString, input, records) { + if ( + input.nodePrincipal.schemeIs("about") || + input.nodePrincipal.isSystemPrincipal + ) { + // Don't show autocomplete results for about: pages. + return null; + } + + let { + generatedPassword, + autocompleteItems, + importable, + logins, + willAutoSaveGeneratedPassword, + } = records ?? {}; + logins ||= []; + + const formOrigin = lazy.LoginHelper.getLoginOrigin( + input.ownerDocument.documentURI + ); + + const isNullPrincipal = input.nodePrincipal.isNullPrincipal; + const form = lazy.LoginFormFactory.createFromField(input); + const isSecure = + !isNullPrincipal && lazy.InsecurePasswordUtils.isFormSecure(form); + + const telemetryEventData = { + acFieldName: input.getAutocompleteInfo().fieldName, + //hadPrevious: !!aPreviousResult, + hadPrevious: false, + typeWasPassword: input.hasBeenTypePassword, + fieldType: input.type, + searchStartTimeMS: this.#searchStartTimeMS, + stringLength: searchString.length, + }; + + const acResult = new lazy.LoginAutoCompleteResult( + searchString, + lazy.LoginHelper.vanillaObjectsToLogins(logins), + autocompleteItems, + formOrigin, + { + generatedPassword, + willAutoSaveGeneratedPassword, + importable, + actor: this, + isSecure, + hasBeenTypePassword: input.hasBeenTypePassword, + hostname: input.ownerDocument.documentURIObject.host, + telemetryEventData, + } + ); + return acResult; + } + + isLoginManagerField(input) { + return input.hasBeenTypePassword || this.#interestedInputs.includes(input); + } + + #cachedNewPasswordScore = new WeakMap(); + + isProbablyANewPasswordField(inputElement) { + const threshold = lazy.LoginHelper.generationConfidenceThreshold; + if (threshold == -1) { + // Fathom is disabled + return false; + } + + let score = this.#cachedNewPasswordScore.get(inputElement); + if (score) { + return score >= threshold; + } + + const { rules, type } = lazy.NewPasswordModel; + const results = rules.against(inputElement); + score = results.get(inputElement).scoreFor(type); + this.#cachedNewPasswordScore.set(inputElement, score); + return score >= threshold; + } + + /** + * @param {string} autocompleteInfo + * @returns whether the non-autofill credential type (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#non-autofill-credential-type) + * of the input field is "webauthn" + */ + #isWebAuthnCredentials(autocompleteInfo) { + return autocompleteInfo.credentialType == "webauthn"; + } + + fillFields(login) { + let { focusedInput } = lazy.gFormFillService; + this.onFieldAutoComplete(focusedInput, login); + } } diff --git a/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs index 862ab6b846..cb3ea2bd7c 100644 --- a/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs +++ b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs @@ -829,15 +829,19 @@ export class LoginManagerParent extends JSWindowActorParent { })) ); } - autocompleteItems.push( - ...(await lazy.WebAuthnFeature.autocompleteItemsAsync( - this._overrideBrowsingContextId ?? - this.getRootBrowser().browsingContext.id, - formOrigin, - scenarioName, - isWebAuthn - )) - ); + // This check is only used to init webauthn in tests, which causes + // intermittent like Bug 1890419. + if (LoginManagerParent._webAuthnAutoComplete) { + autocompleteItems.push( + ...(await lazy.WebAuthnFeature.autocompleteItemsAsync( + this._overrideBrowsingContextId ?? + this.getRootBrowser().browsingContext.id, + formOrigin, + scenarioName, + isWebAuthn + )) + ); + } return { generatedPassword, @@ -1541,6 +1545,27 @@ export class LoginManagerParent extends JSWindowActorParent { return gRecipeManager.initializationPromise; } + + async searchAutoCompleteEntries(searchString, data) { + return this.doAutocompleteSearch(data.formOrigin, data); + } + + previewFields(_result) { + // Logins do not show previews + } + + autofillFields(result) { + if (result.style == "login" || result.style == "loginWithOrigin") { + try { + const profile = JSON.parse(result.comment); + this.sendAsyncMessage("PasswordManager:fillFields", profile.login); + } catch (e) { + lazy.log("Fail to get autofill profile: ", e.message); + } + } else if (result.style == "generatedPassword") { + this.sendAsyncMessage("PasswordManager:fillGeneratedPassword"); + } + } } LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS = 10000; @@ -1551,3 +1576,10 @@ XPCOMUtils.defineLazyPreferenceGetter( "signon.masterPasswordReprompt.timeout_ms", 900000 ); // 15 Minutes + +XPCOMUtils.defineLazyPreferenceGetter( + LoginManagerParent, + "_webAuthnAutoComplete", + "signon.webauthn.autocomplete", + true +); diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build index 4fbb9dcbf4..16e4303702 100644 --- a/toolkit/components/passwordmgr/moz.build +++ b/toolkit/components/passwordmgr/moz.build @@ -16,7 +16,6 @@ TESTING_JS_MODULES += [ ] XPIDL_SOURCES += [ - "nsILoginAutoCompleteSearch.idl", "nsILoginInfo.idl", "nsILoginManager.idl", "nsILoginManagerAuthPrompter.idl", diff --git a/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl b/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl deleted file mode 100644 index 586292d04d..0000000000 --- a/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl +++ /dev/null @@ -1,31 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -#include "nsISupports.idl" - -interface nsIAutoCompleteResult; -interface nsIFormFillCompleteObserver; - -webidl HTMLInputElement; - -[scriptable, uuid(2bdac17c-53f1-4896-a521-682ccdeef3a8)] -interface nsILoginAutoCompleteSearch : nsISupports { - /** - * Generate results for a login field autocomplete menu. - * - * NOTE: This interface is provided for use only by the FormFillController, - * which calls it directly. This isn't really ideal, it should - * probably be callback registered through the FFC. - * NOTE: This API is different than nsIAutoCompleteSearch. - */ - void startSearch(in AString aSearchString, - in nsIAutoCompleteResult aPreviousResult, - in HTMLInputElement aElement, - in nsIFormFillCompleteObserver aListener); - - /** - * Stop a previously-started search. - */ - void stopSearch(); -}; diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html index 54cb450815..b5db098174 100644 --- a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html @@ -146,21 +146,29 @@ Login Manager test: filling generated passwords into confirm password fields "resetLoginsAndGeneratedPasswords", () => { LoginTestUtils.clearData(); LoginTestUtils.resetGeneratedPasswordsCache(); + return Promise.resolve(); } ); }); function resetLoginsAndGeneratedPasswords() { - return setupScript.sendAsyncMessage("resetLoginsAndGeneratedPasswords"); + return setupScript.sendQuery("resetLoginsAndGeneratedPasswords"); } async function triggerPasswordGeneration(form) { await openPopupOn(form.pword); synthesizeKey("KEY_ArrowDown"); synthesizeKey("KEY_Enter"); + + const storageAddPromise = promiseStorageChanged(["addLogin"]); await SimpleTest.promiseWaitForCondition(() => !!form.pword.value, "Wait for generated password to get filled"); + await storageAddPromise; } + add_setup(async () => { + SpecialPowers.pushPrefEnv({"set": [["signon.webauthn.autocomplete", false]]}); + }) + add_named_task("autocomplete menu contains option to generate password", async () => { await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form1); @@ -206,7 +214,7 @@ Login Manager test: filling generated passwords into confirm password fields await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form1); await triggerPasswordGeneration(form); - is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pword.value == form.pwordNext.value, "Value of the confirm field has been filled with generated password"); }); add_named_task("password field is not masked initially after password generation", async () => { @@ -270,7 +278,7 @@ Login Manager test: filling generated passwords into confirm password fields form.pword.blur(); await messageSentPromise; - is(form.pwordNext.value, generatedPassword, "Value of the confirm field still holds the original generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == generatedPassword, "Value of the confirm field still holds the original generated password"); ok(form.pwordNext.matches(":autofill"), "Highlight is still applied to password confirmation field"); }); @@ -314,21 +322,21 @@ Login Manager test: filling generated passwords into confirm password fields form.pwordNext.focus() sendString("edited value"); await triggerPasswordGeneration(form); - is(form.pwordNext.value, "edited value", "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "edited value", "Value of the confirm field has been filled with generated password"); }); add_named_task("password confirmation does not get filled with the generated password if its readonly", async () => { await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form3); await triggerPasswordGeneration(form); - is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "", "Value of the confirm field has been filled with generated password"); }); add_named_task("password confirmation does not get filled with the generated password if its disabled", async () => { await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form4); await triggerPasswordGeneration(form); - is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "", "Value of the confirm field has been filled with generated password"); }); add_named_task("password confirmation matching autocomplete info gets filled with the generated password", async () => { @@ -336,14 +344,14 @@ Login Manager test: filling generated passwords into confirm password fields const form = setContentForTask(formTemplates.form5); await triggerPasswordGeneration(form); is(form.pwordBetween.value, "", "Value of the between field has not been filled"); - is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password"); }); add_named_task("password confirmation matching autocomplete info gets ignored if its disabled, even if has autocomplete info", async () => { await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form6); await triggerPasswordGeneration(form); - is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password"); is(form.pwordAfter.value, "", "Value of the disabled confirmation field has not been filled"); }); @@ -365,7 +373,7 @@ Login Manager test: filling generated passwords into confirm password fields await resetLoginsAndGeneratedPasswords(); const form = setContentForTask(formTemplates.form9); await triggerPasswordGeneration(form); - is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password"); + await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password"); }); add_named_task("do not fill third password field after the confirm-password field", async () => { diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html index 803197c2a2..ae4586f1bb 100644 --- a/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html @@ -22,6 +22,8 @@ const iframe = document.getElementsByTagName("iframe")[0]; let iframeDoc, hostname; add_setup(async () => { + SpecialPowers.pushPrefEnv({"set": [["signon.webauthn.autocomplete", false]]}); + const origin = window.location.origin; await setStoredLoginsAsync( [origin, origin, null, "name", "pass"], @@ -96,9 +98,9 @@ add_task(async function test_not_reopened_after_selecting() { listenForUnexpectedPopupShown(); await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() { - let formFillController = SpecialPowers.getFormFillController(); + const actor = content.windowGlobalChild.getActor("AutoComplete"); let usernameField = this.content.document.getElementById("form-basic-username"); - formFillController.markAsLoginManagerField(usernameField); + actor.markAsAutoCompletableField(usernameField); }); info("Waiting to see if a popupshown occurs"); diff --git a/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js index 167c160da2..0bc88734d2 100644 --- a/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js +++ b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js @@ -4,9 +4,10 @@ "use strict"; -const LoginAutoComplete = Cc[ - "@mozilla.org/login-manager/autocompletesearch;1" -].getService(Ci.nsILoginAutoCompleteSearch).wrappedJSObject; +const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" +); + // TODO: create a fake window for the test document to pass fathom.isVisible check. // We should consider moving these tests to mochitest because many fathom // signals rely on visibility, position, etc., of the test element (See Bug 1712699), @@ -143,9 +144,10 @@ add_task(async function test_returns_false_when_pref_disabled() { "http://localhost:8080/test/", testcase.document ); + const lmc = new LoginManagerChild(); for (let [i, input] of testcase.inputs || document.querySelectorAll(`input[type="password"]`).entries()) { - const result = LoginAutoComplete.isProbablyANewPasswordField(input); + const result = lmc.isProbablyANewPasswordField(input); Assert.strictEqual( result, false, @@ -172,10 +174,11 @@ for (let testcase of TESTCASES) { document = makeDocumentVisibleToFathom(document); + const lmc = new LoginManagerChild(); const results = []; for (let input of testcase.inputs || document.querySelectorAll(`input[type="password"]`)) { - const result = LoginAutoComplete.isProbablyANewPasswordField(input); + const result = lmc.isProbablyANewPasswordField(input); results.push(result); } -- cgit v1.2.3