diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /toolkit/components/passwordmgr/LoginHelper.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginHelper.sys.mjs')
-rw-r--r-- | toolkit/components/passwordmgr/LoginHelper.sys.mjs | 1891 |
1 files changed, 1891 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginHelper.sys.mjs b/toolkit/components/passwordmgr/LoginHelper.sys.mjs new file mode 100644 index 0000000000..e22f0552ce --- /dev/null +++ b/toolkit/components/passwordmgr/LoginHelper.sys.mjs @@ -0,0 +1,1891 @@ +/* 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/. */ + +/** + * Contains functions shared by different Login Manager components. + * + * This JavaScript module exists in order to share code between the different + * XPCOM components that constitute the Login Manager, including implementations + * of nsILoginManager and nsILoginManagerStorage. + */ + +import { Logic } from "resource://gre/modules/LoginManager.shared.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +export class ParentAutocompleteOption { + icon; + title; + subtitle; + fillMessageName; + fillMessageData; + + constructor(icon, title, subtitle, fillMessageName, fillMessageData) { + this.icon = icon; + this.title = title; + this.subtitle = subtitle; + this.fillMessageName = fillMessageName; + this.fillMessageData = fillMessageData; + } +} + +/** + * A helper class to deal with CSV import rows. + */ +class ImportRowProcessor { + uniqueLoginIdentifiers = new Set(); + originToRows = new Map(); + summary = []; + mandatoryFields = ["origin", "password"]; + + /** + * Validates if the login data contains a GUID that was already found in a previous row in the current import. + * If this is the case, the summary will be updated with an error. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is an error, false otherwise. + */ + checkNonUniqueGuidError(loginData) { + if (loginData.guid) { + if (this.uniqueLoginIdentifiers.has(loginData.guid)) { + this.addLoginToSummary({ ...loginData }, "error"); + return true; + } + this.uniqueLoginIdentifiers.add(loginData.guid); + } + return false; + } + + /** + * Validates if the login data contains invalid fields that are mandatory like origin and password. + * If this is the case, the summary will be updated with an error. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is an error, false otherwise. + */ + checkMissingMandatoryFieldsError(loginData) { + loginData.origin = LoginHelper.getLoginOrigin(loginData.origin); + for (let mandatoryField of this.mandatoryFields) { + if (!loginData[mandatoryField]) { + const missingFieldRow = this.addLoginToSummary( + { ...loginData }, + "error_missing_field" + ); + missingFieldRow.field_name = mandatoryField; + return true; + } + } + return false; + } + + /** + * Validates if there is already an existing entry with similar values. + * If there are similar values but not identical, a new "modified" entry will be added to the summary. + * If there are identical values, a new "no_change" entry will be added to the summary + * If either of these is the case, it will return true. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + async checkExistingEntry(loginData) { + if (loginData.guid) { + // First check for `guid` matches if it's set. + // `guid` matches will allow every kind of update, including reverting + // to older passwords which can be useful if the user wants to recover + // an old password. + let existingLogins = await Services.logins.searchLoginsAsync({ + guid: loginData.guid, + origin: loginData.origin, // Ignored outside of GV. + }); + + if (existingLogins.length) { + lazy.log.debug("maybeImportLogins: Found existing login with GUID."); + // There should only be one `guid` match. + let existingLogin = existingLogins[0].QueryInterface( + Ci.nsILoginMetaInfo + ); + + if ( + loginData.username !== existingLogin.username || + loginData.password !== existingLogin.password || + loginData.httpRealm !== existingLogin.httpRealm || + loginData.formActionOrigin !== existingLogin.formActionOrigin || + `${loginData.timeCreated}` !== `${existingLogin.timeCreated}` || + `${loginData.timePasswordChanged}` !== + `${existingLogin.timePasswordChanged}` + ) { + // Use a property bag rather than an nsILoginInfo so we don't clobber + // properties that the import source doesn't provide. + let propBag = LoginHelper.newPropertyBag(loginData); + this.addLoginToSummary({ ...existingLogin }, "modified", propBag); + return true; + } + this.addLoginToSummary({ ...existingLogin }, "no_change"); + return true; + } + } + return false; + } + + /** + * Validates if there is a conflict with previous rows based on the origin. + * We need to check the logins that we've already decided to add, to see if this is a duplicate. + * If this is the case, we mark this one as "no_change" in the summary and return true. + * @param {object} login + * A login object. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + checkConflictingOriginWithPreviousRows(login) { + let rowsPerOrigin = this.originToRows.get(login.origin); + if (rowsPerOrigin) { + if ( + rowsPerOrigin.some(r => + login.matches(r.login, false /* ignorePassword */) + ) + ) { + this.addLoginToSummary(login, "no_change"); + return true; + } + for (let row of rowsPerOrigin) { + let newLogin = row.login; + if (login.username == newLogin.username) { + this.addLoginToSummary(login, "no_change"); + return true; + } + } + } + return false; + } + + /** + * Validates if there is a conflict with existing logins based on the origin. + * If this is the case and there are some changes, we mark it as "modified" in the summary. + * If it matches an existing login without any extra modifications, we mark it as "no_change". + * For both cases we return true. + * @param {object} login + * A login object. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + checkConflictingWithExistingLogins(login) { + // While here we're passing formActionOrigin and httpRealm, they could be empty/null and get + // ignored in that case, leading to multiple logins for the same username. + let existingLogins = Services.logins.findLogins( + login.origin, + login.formActionOrigin, + login.httpRealm + ); + // Check for an existing login that matches *including* the password. + // If such a login exists, we do not need to add a new login. + if ( + existingLogins.some(l => login.matches(l, false /* ignorePassword */)) + ) { + this.addLoginToSummary(login, "no_change"); + return true; + } + // Now check for a login with the same username, where it may be that we have an + // updated password. + let foundMatchingLogin = false; + for (let existingLogin of existingLogins) { + if (login.username == existingLogin.username) { + foundMatchingLogin = true; + existingLogin.QueryInterface(Ci.nsILoginMetaInfo); + if ( + (login.password != existingLogin.password) & + (login.timePasswordChanged > existingLogin.timePasswordChanged) + ) { + // if a login with the same username and different password already exists and it's older + // than the current one, update its password and timestamp. + let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("password", login.password); + propBag.setProperty("timePasswordChanged", login.timePasswordChanged); + this.addLoginToSummary({ ...existingLogin }, "modified", propBag); + return true; + } + } + } + // if the new login is an update or is older than an exiting login, don't add it. + if (foundMatchingLogin) { + this.addLoginToSummary(login, "no_change"); + return true; + } + return false; + } + + /** + * Validates if there are any invalid values using LoginHelper.checkLoginValues. + * If this is the case we mark it as "error" and return true. + * @param {object} login + * A login object. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is a validation error we return true, false otherwise. + */ + checkLoginValuesError(login, loginData) { + try { + // Ensure we only send checked logins through, since the validation is optimized + // out from the bulk APIs below us. + LoginHelper.checkLoginValues(login); + } catch (e) { + this.addLoginToSummary({ ...loginData }, "error"); + console.error(e); + return true; + } + return false; + } + + /** + * Creates a new login from loginData. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {object} A login object. + */ + createNewLogin(loginData) { + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init( + loginData.origin, + loginData.formActionOrigin, + loginData.httpRealm, + loginData.username, + loginData.password, + loginData.usernameElement || "", + loginData.passwordElement || "" + ); + + login.QueryInterface(Ci.nsILoginMetaInfo); + login.timeCreated = loginData.timeCreated; + login.timeLastUsed = loginData.timeLastUsed || loginData.timeCreated; + login.timePasswordChanged = + loginData.timePasswordChanged || loginData.timeCreated; + login.timesUsed = loginData.timesUsed || 1; + login.guid = loginData.guid || null; + return login; + } + + /** + * Cleans the action and realm field of the loginData. + * @param {object} loginData + * An vanilla object for the login without any methods. + */ + cleanupActionAndRealmFields(loginData) { + const cleanOrigin = loginData.formActionOrigin + ? LoginHelper.getLoginOrigin(loginData.formActionOrigin, true) + : ""; + loginData.formActionOrigin = + cleanOrigin || (typeof loginData.httpRealm == "string" ? null : ""); + + loginData.httpRealm = + typeof loginData.httpRealm == "string" ? loginData.httpRealm : null; + } + + /** + * Adds a login to the summary. + * @param {object} login + * A login object. + * @param {string} result + * The result type. One of "added", "modified", "error", "error_invalid_origin", "error_invalid_password" or "no_change". + * @param {object} propBag + * An optional parameter with the properties bag. + * @returns {object} The row that was added. + */ + addLoginToSummary(login, result, propBag) { + let rows = this.originToRows.get(login.origin) || []; + if (rows.length === 0) { + this.originToRows.set(login.origin, rows); + } + const newSummaryRow = { result, login, propBag }; + rows.push(newSummaryRow); + this.summary.push(newSummaryRow); + return newSummaryRow; + } + + /** + * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor. + * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added". + * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change". + */ + markLastTimePasswordChangedAsModified() { + const originUserToRowMap = new Map(); + for (let currentRow of this.summary) { + if ( + currentRow.result === "added" || + currentRow.result === "modified" || + currentRow.result === "no_change" + ) { + const originAndUser = + currentRow.login.origin + currentRow.login.username; + let lastTimeChangedRow = originUserToRowMap.get(originAndUser); + if (lastTimeChangedRow) { + if ( + (currentRow.login.password != lastTimeChangedRow.login.password) & + (currentRow.login.timePasswordChanged > + lastTimeChangedRow.login.timePasswordChanged) + ) { + lastTimeChangedRow.result = "no_change"; + currentRow.result = "added"; + originUserToRowMap.set(originAndUser, currentRow); + } + } else { + originUserToRowMap.set(originAndUser, currentRow); + } + } + } + } + + /** + * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor. + * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added". + * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change". + * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data. + */ + async processLoginsAndBuildSummary() { + this.markLastTimePasswordChangedAsModified(); + for (let summaryRow of this.summary) { + try { + if (summaryRow.result === "added") { + summaryRow.login = await Services.logins.addLoginAsync( + summaryRow.login + ); + } else if (summaryRow.result === "modified") { + Services.logins.modifyLogin(summaryRow.login, summaryRow.propBag); + } + } catch (e) { + console.error(e); + summaryRow.result = "error"; + } + } + return this.summary; + } +} + +/** + * Contains functions shared by different Login Manager components. + */ +export const LoginHelper = { + debug: null, + enabled: null, + storageEnabled: null, + formlessCaptureEnabled: null, + formRemovalCaptureEnabled: null, + generationAvailable: null, + generationConfidenceThreshold: null, + generationEnabled: null, + improvedPasswordRulesEnabled: null, + improvedPasswordRulesCollection: "password-rules", + includeOtherSubdomainsInLookup: null, + insecureAutofill: null, + privateBrowsingCaptureEnabled: null, + remoteRecipesEnabled: null, + remoteRecipesCollection: "password-recipes", + relatedRealmsEnabled: null, + relatedRealmsCollection: "websites-with-shared-credential-backends", + schemeUpgrades: null, + showAutoCompleteFooter: null, + showAutoCompleteImport: null, + signupDectectionConfidenceThreshold: null, + testOnlyUserHasInteractedWithDocument: null, + userInputRequiredToCapture: null, + captureInputChanges: null, + + init() { + // Watch for pref changes to update cached pref values. + Services.prefs.addObserver("signon.", () => this.updateSignonPrefs()); + this.updateSignonPrefs(); + Services.telemetry.setEventRecordingEnabled("pwmgr", true); + Services.telemetry.setEventRecordingEnabled("form_autocomplete", true); + }, + + updateSignonPrefs() { + this.autofillForms = Services.prefs.getBoolPref("signon.autofillForms"); + this.autofillAutocompleteOff = Services.prefs.getBoolPref( + "signon.autofillForms.autocompleteOff" + ); + this.captureInputChanges = Services.prefs.getBoolPref( + "signon.capture.inputChanges.enabled" + ); + this.debug = Services.prefs.getBoolPref("signon.debug"); + this.enabled = Services.prefs.getBoolPref("signon.rememberSignons"); + this.storageEnabled = Services.prefs.getBoolPref( + "signon.storeSignons", + true + ); + this.formlessCaptureEnabled = Services.prefs.getBoolPref( + "signon.formlessCapture.enabled" + ); + this.formRemovalCaptureEnabled = Services.prefs.getBoolPref( + "signon.formRemovalCapture.enabled" + ); + this.generationAvailable = Services.prefs.getBoolPref( + "signon.generation.available" + ); + this.generationConfidenceThreshold = parseFloat( + Services.prefs.getStringPref("signon.generation.confidenceThreshold") + ); + this.generationEnabled = Services.prefs.getBoolPref( + "signon.generation.enabled" + ); + this.improvedPasswordRulesEnabled = Services.prefs.getBoolPref( + "signon.improvedPasswordRules.enabled" + ); + this.insecureAutofill = Services.prefs.getBoolPref( + "signon.autofillForms.http" + ); + this.includeOtherSubdomainsInLookup = Services.prefs.getBoolPref( + "signon.includeOtherSubdomainsInLookup" + ); + this.passwordEditCaptureEnabled = Services.prefs.getBoolPref( + "signon.passwordEditCapture.enabled" + ); + this.privateBrowsingCaptureEnabled = Services.prefs.getBoolPref( + "signon.privateBrowsingCapture.enabled" + ); + this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades"); + this.showAutoCompleteFooter = Services.prefs.getBoolPref( + "signon.showAutoCompleteFooter" + ); + + this.showAutoCompleteImport = Services.prefs.getStringPref( + "signon.showAutoCompleteImport", + "" + ); + this.signupDetectionConfidenceThreshold = parseFloat( + Services.prefs.getStringPref("signon.signupDetection.confidenceThreshold") + ); + this.signupDetectionEnabled = Services.prefs.getBoolPref( + "signon.signupDetection.enabled" + ); + + this.storeWhenAutocompleteOff = Services.prefs.getBoolPref( + "signon.storeWhenAutocompleteOff" + ); + + this.suggestImportCount = Services.prefs.getIntPref( + "signon.suggestImportCount", + 0 + ); + + if ( + Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + false + ) + ) { + this.testOnlyUserHasInteractedWithDocument = Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedWithDocument", + false + ); + lazy.log.debug( + `Using pref value for testOnlyUserHasInteractedWithDocument ${this.testOnlyUserHasInteractedWithDocument}.` + ); + } else { + this.testOnlyUserHasInteractedWithDocument = null; + } + + this.userInputRequiredToCapture = Services.prefs.getBoolPref( + "signon.userInputRequiredToCapture.enabled" + ); + this.usernameOnlyFormEnabled = Services.prefs.getBoolPref( + "signon.usernameOnlyForm.enabled" + ); + this.usernameOnlyFormLookupThreshold = Services.prefs.getIntPref( + "signon.usernameOnlyForm.lookupThreshold" + ); + this.remoteRecipesEnabled = Services.prefs.getBoolPref( + "signon.recipes.remoteRecipes.enabled" + ); + this.relatedRealmsEnabled = Services.prefs.getBoolPref( + "signon.relatedRealms.enabled" + ); + }, + + createLogger(aLogPrefix) { + let getMaxLogLevel = () => { + return this.debug ? "Debug" : "Warn"; + }; + + // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. + let consoleOptions = { + maxLogLevel: getMaxLogLevel(), + prefix: aLogPrefix, + }; + let logger = console.createInstance(consoleOptions); + + // Watch for pref changes and update this.debug and the maxLogLevel for created loggers + Services.prefs.addObserver("signon.debug", () => { + this.debug = Services.prefs.getBoolPref("signon.debug"); + if (logger) { + logger.maxLogLevel = getMaxLogLevel(); + } + }); + + return logger; + }, + + /** + * Due to the way the signons2.txt file is formatted, we need to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. Reject origins that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkOriginValue(aOrigin) { + // Nulls are invalid, as they don't round-trip well. Newlines are also + // invalid for any field stored as plaintext, and an origin made of a + // single dot cannot be stored in the legacy format. + if ( + aOrigin == "." || + aOrigin.includes("\r") || + aOrigin.includes("\n") || + aOrigin.includes("\0") + ) { + throw new Error("Invalid origin"); + } + }, + + /** + * Due to the way the signons2.txt file was formatted, we needed to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. These characters can cause problems in other + * formats/languages too so reject logins that may not be stored correctly. + * + * @throws String with English message in case validation failed. + */ + checkLoginValues(aLogin) { + function badCharacterPresent(l, c) { + return ( + (l.formActionOrigin && l.formActionOrigin.includes(c)) || + (l.httpRealm && l.httpRealm.includes(c)) || + l.origin.includes(c) || + l.usernameField.includes(c) || + l.passwordField.includes(c) + ); + } + + // Nulls are invalid, as they don't round-trip well. + // Mostly not a formatting problem, although ".\0" can be quirky. + if (badCharacterPresent(aLogin, "\0")) { + throw new Error("login values can't contain nulls"); + } + + if (!aLogin.password || typeof aLogin.password != "string") { + throw new Error("passwords must be non-empty strings"); + } + + // In theory these nulls should just be rolled up into the encrypted + // values, but nsISecretDecoderRing doesn't use nsStrings, so the + // nulls cause truncation. Check for them here just to avoid + // unexpected round-trip surprises. + if (aLogin.username.includes("\0") || aLogin.password.includes("\0")) { + throw new Error("login values can't contain nulls"); + } + + // Newlines are invalid for any field stored as plaintext. + if ( + badCharacterPresent(aLogin, "\r") || + badCharacterPresent(aLogin, "\n") + ) { + throw new Error("login values can't contain newlines"); + } + + // A line with just a "." can have special meaning. + if (aLogin.usernameField == "." || aLogin.formActionOrigin == ".") { + throw new Error("login values can't be periods"); + } + + // An origin with "\ \(" won't roundtrip. + // eg host="foo (", realm="bar" --> "foo ( (bar)" + // vs host="foo", realm=" (bar" --> "foo ( (bar)" + if (aLogin.origin.includes(" (")) { + throw new Error("bad parens in origin"); + } + }, + + /** + * Returns a new XPCOM property bag with the provided properties. + * + * @param {Object} aProperties + * Each property of this object is copied to the property bag. This + * parameter can be omitted to return an empty property bag. + * + * @return A new property bag, that is an instance of nsIWritablePropertyBag, + * nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2. + */ + newPropertyBag(aProperties) { + let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + if (aProperties) { + for (let [name, value] of Object.entries(aProperties)) { + propertyBag.setProperty(name, value); + } + } + return propertyBag + .QueryInterface(Ci.nsIPropertyBag) + .QueryInterface(Ci.nsIPropertyBag2) + .QueryInterface(Ci.nsIWritablePropertyBag2); + }, + + /** + * Helper to avoid the property bags when calling + * Services.logins.searchLogins from JS. + * @deprecated Use Services.logins.searchLoginsAsync instead. + * + * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching + * @return {nsILoginInfo[]} - The result of calling searchLogins. + */ + searchLoginsWithObject(aSearchOptions) { + return Services.logins.searchLogins(this.newPropertyBag(aSearchOptions)); + }, + + /** + * @param {string} aURL + * @returns {string} which is the hostPort of aURL if supported by the scheme + * otherwise, returns the original aURL. + */ + maybeGetHostPortForURL(aURL) { + try { + let uri = Services.io.newURI(aURL); + return uri.hostPort; + } catch (ex) { + // No need to warn for javascript:/data:/about:/chrome:/etc. + } + return aURL; + }, + + /** + * Get the parts of the URL we want for identification. + * Strip out things like the userPass portion and handle javascript:. + */ + getLoginOrigin(uriString, allowJS = false) { + let realm = ""; + try { + const mozProxyRegex = /^moz-proxy:\/\//i; + const isMozProxy = !!uriString.match(mozProxyRegex); + if (isMozProxy) { + // Special handling because uri.displayHostPort throws on moz-proxy:// + return ( + "moz-proxy://" + + Services.io.newURI(uriString.replace(mozProxyRegex, "https://")) + .displayHostPort + ); + } + + let uri = Services.io.newURI(uriString); + + if (allowJS && uri.scheme == "javascript") { + return "javascript:"; + } + + // Build this manually instead of using prePath to avoid including the userPass portion. + realm = uri.scheme + "://" + uri.displayHostPort; + } catch (e) { + // bug 159484 - disallow url types that don't support a hostPort. + // (although we handle "javascript:..." as a special case above.) + if (uriString && !uriString.startsWith("data")) { + lazy.log.warn( + `Couldn't parse specified uri ${uriString} with error ${e.name}` + ); + } + realm = null; + } + + return realm; + }, + + getFormActionOrigin(form) { + let uriString = form.action; + + // A blank or missing action submits to where it came from. + if (uriString == "") { + // ala bug 297761 + uriString = form.baseURI; + } + + return this.getLoginOrigin(uriString, true); + }, + + /** + * @param {String} aLoginOrigin - An origin value from a stored login's + * origin or formActionOrigin properties. + * @param {String} aSearchOrigin - The origin that was are looking to match + * with aLoginOrigin. This would normally come + * from a form or page that we are considering. + * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin + * from the login (aLoginOrigin) is a + * match for the origin we're looking + * for (aSearchOrigin). + */ + isOriginMatching( + aLoginOrigin, + aSearchOrigin, + aOptions = { + schemeUpgrades: false, + acceptWildcardMatch: false, + acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], + } + ) { + if (aLoginOrigin == aSearchOrigin) { + return true; + } + + if (!aOptions) { + return false; + } + + if (aOptions.acceptWildcardMatch && aLoginOrigin == "") { + return true; + } + + // We can only match logins now if either of these flags are true, so + // avoid doing the work of constructing URL objects if neither is true. + if (!aOptions.acceptDifferentSubdomains && !aOptions.schemeUpgrades) { + return false; + } + + try { + let loginURI = Services.io.newURI(aLoginOrigin); + let searchURI = Services.io.newURI(aSearchOrigin); + let schemeMatches = + loginURI.scheme == "http" && searchURI.scheme == "https"; + + if (aOptions.acceptDifferentSubdomains) { + let loginBaseDomain = Services.eTLD.getBaseDomain(loginURI); + let searchBaseDomain = Services.eTLD.getBaseDomain(searchURI); + if ( + loginBaseDomain == searchBaseDomain && + (loginURI.scheme == searchURI.scheme || + (aOptions.schemeUpgrades && schemeMatches)) + ) { + return true; + } + if ( + aOptions.acceptRelatedRealms && + aOptions.relatedRealms.length && + (loginURI.scheme == searchURI.scheme || + (aOptions.schemeUpgrades && schemeMatches)) + ) { + for (let relatedOrigin of aOptions.relatedRealms) { + if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) { + return true; + } + } + } + } + + if ( + aOptions.schemeUpgrades && + loginURI.host == searchURI.host && + schemeMatches && + loginURI.port == searchURI.port + ) { + return true; + } + } catch (ex) { + // newURI will throw for some values e.g. chrome://FirefoxAccounts + // uri.host and uri.port will throw for some values e.g. javascript: + return false; + } + + return false; + }, + + doLoginsMatch( + aLogin1, + aLogin2, + { ignorePassword = false, ignoreSchemes = false } + ) { + if ( + aLogin1.httpRealm != aLogin2.httpRealm || + aLogin1.username != aLogin2.username + ) { + return false; + } + + if (!ignorePassword && aLogin1.password != aLogin2.password) { + return false; + } + + if (ignoreSchemes) { + let login1HostPort = this.maybeGetHostPortForURL(aLogin1.origin); + let login2HostPort = this.maybeGetHostPortForURL(aLogin2.origin); + if (login1HostPort != login2HostPort) { + return false; + } + + if ( + aLogin1.formActionOrigin != "" && + aLogin2.formActionOrigin != "" && + this.maybeGetHostPortForURL(aLogin1.formActionOrigin) != + this.maybeGetHostPortForURL(aLogin2.formActionOrigin) + ) { + return false; + } + } else { + if (aLogin1.origin != aLogin2.origin) { + return false; + } + + // If either formActionOrigin is blank (but not null), then match. + if ( + aLogin1.formActionOrigin != "" && + aLogin2.formActionOrigin != "" && + aLogin1.formActionOrigin != aLogin2.formActionOrigin + ) { + return false; + } + } + + // The .usernameField and .passwordField values are ignored. + + return true; + }, + + /** + * Creates a new login object that results by modifying the given object with + * the provided data. + * + * @param {nsILoginInfo} aOldStoredLogin + * Existing login object to modify. + * @param {nsILoginInfo|nsIProperyBag} aNewLoginData + * The new login values, either as an nsILoginInfo or nsIProperyBag. + * + * @return {nsILoginInfo} The newly created nsILoginInfo object. + * + * @throws {Error} With English message in case validation failed. + */ + buildModifiedLogin(aOldStoredLogin, aNewLoginData) { + function bagHasProperty(aPropName) { + try { + aNewLoginData.getProperty(aPropName); + return true; + } catch (ex) {} + return false; + } + + aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo); + + let newLogin; + if (aNewLoginData instanceof Ci.nsILoginInfo) { + // Clone the existing login to get its nsILoginMetaInfo, then init it + // with the replacement nsILoginInfo data from the new login. + newLogin = aOldStoredLogin.clone(); + newLogin.init( + aNewLoginData.origin, + aNewLoginData.formActionOrigin, + aNewLoginData.httpRealm, + aNewLoginData.username, + aNewLoginData.password, + aNewLoginData.usernameField, + aNewLoginData.passwordField + ); + newLogin.unknownFields = aNewLoginData.unknownFields; + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + if (newLogin.password != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } else if (aNewLoginData instanceof Ci.nsIPropertyBag) { + // Clone the existing login, along with all its properties. + newLogin = aOldStoredLogin.clone(); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + // (Done before the main property updates, lest the caller be + // explicitly updating both .password and .timePasswordChanged) + if (bagHasProperty("password")) { + let newPassword = aNewLoginData.getProperty("password"); + if (newPassword != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } + + for (let prop of aNewLoginData.enumerator) { + switch (prop.name) { + // nsILoginInfo (fall through) + case "origin": + case "httpRealm": + case "formActionOrigin": + case "username": + case "password": + case "usernameField": + case "passwordField": + case "unknownFields": + // nsILoginMetaInfo (fall through) + case "guid": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + newLogin[prop.name] = prop.value; + break; + + // Fake property, allows easy incrementing. + case "timesUsedIncrement": + newLogin.timesUsed += prop.value; + break; + + // Fail if caller requests setting an unknown property. + default: + throw new Error("Unexpected propertybag item: " + prop.name); + } + } + } else { + throw new Error("newLoginData needs an expected interface!"); + } + + // Sanity check the login + if (newLogin.origin == null || !newLogin.origin.length) { + throw new Error("Can't add a login with a null or empty origin."); + } + + // For logins w/o a username, set to "", not null. + if (newLogin.username == null) { + throw new Error("Can't add a login with a null username."); + } + + if (newLogin.password == null || !newLogin.password.length) { + throw new Error("Can't add a login with a null or empty password."); + } + + if (newLogin.formActionOrigin || newLogin.formActionOrigin == "") { + // We have a form submit URL. Can't have a HTTP realm. + if (newLogin.httpRealm != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else if (newLogin.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (newLogin.formActionOrigin != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else { + // Need one or the other! + throw new Error( + "Can't add a login without a httpRealm or formActionOrigin." + ); + } + + // Throws if there are bogus values. + this.checkLoginValues(newLogin); + + return newLogin; + }, + + /** + * Remove http: logins when there is an https: login with the same username and hostPort. + * Sort order is preserved. + * + * @param {nsILoginInfo[]} logins + * A list of logins we want to process for shadowing. + * @returns {nsILoginInfo[]} A subset of of the passed logins. + */ + shadowHTTPLogins(logins) { + /** + * Map a (hostPort, username) to a boolean indicating whether `logins` + * contains an https: login for that combo. + */ + let hasHTTPSByHostPortUsername = new Map(); + for (let login of logins) { + let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); + let hasHTTPSlogin = hasHTTPSByHostPortUsername.get(key) || false; + let loginURI = Services.io.newURI(login.origin); + hasHTTPSByHostPortUsername.set( + key, + loginURI.scheme == "https" || hasHTTPSlogin + ); + } + + return logins.filter(login => { + let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); + let loginURI = Services.io.newURI(login.origin); + if (loginURI.scheme == "http" && hasHTTPSByHostPortUsername.get(key)) { + // If this is an http: login and we have an https: login for the + // (hostPort, username) combo then remove it. + return false; + } + return true; + }); + }, + + /** + * Generate a unique key string from a login. + * @param {nsILoginInfo} login + * @param {string[]} uniqueKeys containing nsILoginInfo attribute names or "hostPort" + * @returns {string} to use as a key in a Map + */ + getUniqueKeyForLogin(login, uniqueKeys) { + const KEY_DELIMITER = ":"; + return uniqueKeys.reduce((prev, key) => { + let val = null; + if (key == "hostPort") { + val = Services.io.newURI(login.origin).hostPort; + } else { + val = login[key]; + } + + return prev + KEY_DELIMITER + val; + }, ""); + }, + + /** + * Removes duplicates from a list of logins while preserving the sort order. + * + * @param {nsILoginInfo[]} logins + * A list of logins we want to deduplicate. + * @param {string[]} [uniqueKeys = ["username", "password"]] + * A list of login attributes to use as unique keys for the deduplication. + * @param {string[]} [resolveBy = ["timeLastUsed"]] + * Ordered array of keyword strings used to decide which of the + * duplicates should be used. "scheme" would prefer the login that has + * a scheme matching `preferredOrigin`'s if there are two logins with + * the same `uniqueKeys`. The default preference to distinguish two + * logins is `timeLastUsed`. If there is no preference between two + * logins, the first one found wins. + * @param {string} [preferredOrigin = undefined] + * String representing the origin to use for preferring one login over + * another when they are dupes. This is used with "scheme" for + * `resolveBy` so the scheme from this origin will be preferred. + * @param {string} [preferredFormActionOrigin = undefined] + * String representing the action origin to use for preferring one login over + * another when they are dupes. This is used with "actionOrigin" for + * `resolveBy` so the scheme from this action origin will be preferred. + * + * @returns {nsILoginInfo[]} list of unique logins. + */ + dedupeLogins( + logins, + uniqueKeys = ["username", "password"], + resolveBy = ["timeLastUsed"], + preferredOrigin = undefined, + preferredFormActionOrigin = undefined + ) { + if (!preferredOrigin) { + if (resolveBy.includes("scheme")) { + throw new Error( + "dedupeLogins: `preferredOrigin` is required in order to " + + "prefer schemes which match it." + ); + } + if (resolveBy.includes("subdomain")) { + throw new Error( + "dedupeLogins: `preferredOrigin` is required in order to " + + "prefer subdomains which match it." + ); + } + } + + let preferredOriginScheme; + if (preferredOrigin) { + try { + preferredOriginScheme = Services.io.newURI(preferredOrigin).scheme; + } catch (ex) { + // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts + } + } + + if (!preferredOriginScheme && resolveBy.includes("scheme")) { + lazy.log.warn( + "Deduping with a scheme preference but couldn't get the preferred origin scheme." + ); + } + + // We use a Map to easily lookup logins by their unique keys. + let loginsByKeys = new Map(); + + /** + * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`) + * `existingLogin`. + * + * `resolveBy` is a sorted array so we can return true the first time `login` is preferred + * over the existingLogin. + */ + function isLoginPreferred(existingLogin, login) { + if (!resolveBy || !resolveBy.length) { + // If there is no preference, prefer the existing login. + return false; + } + + for (let preference of resolveBy) { + switch (preference) { + case "actionOrigin": { + if (!preferredFormActionOrigin) { + break; + } + if ( + LoginHelper.isOriginMatching( + existingLogin.formActionOrigin, + preferredFormActionOrigin, + { schemeUpgrades: LoginHelper.schemeUpgrades } + ) && + !LoginHelper.isOriginMatching( + login.formActionOrigin, + preferredFormActionOrigin, + { schemeUpgrades: LoginHelper.schemeUpgrades } + ) + ) { + return false; + } + break; + } + case "scheme": { + if (!preferredOriginScheme) { + break; + } + + try { + // Only `origin` is currently considered + let existingLoginURI = Services.io.newURI(existingLogin.origin); + let loginURI = Services.io.newURI(login.origin); + // If the schemes of the two logins are the same or neither match the + // preferredOriginScheme then we have no preference and look at the next resolveBy. + if ( + loginURI.scheme == existingLoginURI.scheme || + (loginURI.scheme != preferredOriginScheme && + existingLoginURI.scheme != preferredOriginScheme) + ) { + break; + } + + return loginURI.scheme == preferredOriginScheme; + } catch (e) { + // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts) + lazy.log.debug( + "dedupeLogins/shouldReplaceExisting: Error comparing schemes:", + existingLogin.origin, + login.origin, + "preferredOrigin:", + preferredOrigin, + e.name + ); + } + break; + } + case "subdomain": { + // Replace the existing login only if the new login is an exact match on the host. + let existingLoginURI = Services.io.newURI(existingLogin.origin); + let newLoginURI = Services.io.newURI(login.origin); + let preferredOriginURI = Services.io.newURI(preferredOrigin); + if ( + existingLoginURI.hostPort != preferredOriginURI.hostPort && + newLoginURI.hostPort == preferredOriginURI.hostPort + ) { + return true; + } + if ( + existingLoginURI.host != preferredOriginURI.host && + newLoginURI.host == preferredOriginURI.host + ) { + return true; + } + // if the existing login host *is* a match and the new one isn't + // we explicitly want to keep the existing one + if ( + existingLoginURI.host == preferredOriginURI.host && + newLoginURI.host != preferredOriginURI.host + ) { + return false; + } + break; + } + case "timeLastUsed": + case "timePasswordChanged": { + // If we find a more recent login for the same key, replace the existing one. + let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[ + preference + ]; + let storedLoginDate = existingLogin.QueryInterface( + Ci.nsILoginMetaInfo + )[preference]; + if (loginDate == storedLoginDate) { + break; + } + + return loginDate > storedLoginDate; + } + default: { + throw new Error( + "dedupeLogins: Invalid resolveBy preference: " + preference + ); + } + } + } + + return false; + } + + for (let login of logins) { + let key = this.getUniqueKeyForLogin(login, uniqueKeys); + + if (loginsByKeys.has(key)) { + if (!isLoginPreferred(loginsByKeys.get(key), login)) { + // If there is no preference for the new login, use the existing one. + continue; + } + } + loginsByKeys.set(key, login); + } + + // Return the map values in the form of an array. + return [...loginsByKeys.values()]; + }, + + /** + * Open the password manager window. + * + * @param {Window} window + * the window from where we want to open the dialog + * + * @param {object?} args + * params for opening the password manager + * @param {string} [args.filterString=""] + * the domain (not origin) to pass to the login manager dialog + * to pre-filter the results + * @param {string} args.entryPoint + * The name of the entry point, used for telemetry + */ + openPasswordManager( + window, + { filterString = "", entryPoint = "", loginGuid = null } = {} + ) { + // Get currently active tab's origin + const openedFrom = + window.gBrowser?.selectedTab.linkedBrowser.currentURI.spec; + + // If no loginGuid is set, get sanitized origin, this will return null for about:* uris + const preselectedLogin = loginGuid ?? this.getLoginOrigin(openedFrom); + + const params = new URLSearchParams({ + ...(filterString && { filter: filterString }), + ...(entryPoint && { entryPoint }), + }); + + const paramsPart = params.toString() ? `?${params}` : ""; + const fragmentsPart = preselectedLogin + ? `#${window.encodeURIComponent(preselectedLogin)}` + : ""; + const destination = `about:logins${paramsPart}${fragmentsPart}`; + + // We assume that managementURL has a '?' already + window.openTrustedLinkIn(destination, "tab"); + }, + + /** + * Checks if a field type is password compatible. + * + * @param {Element} element + * the field we want to check. + * @param {Object} options + * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected + * of the element. + * + * @returns {Boolean} true if the field can + * be treated as a password input + */ + isPasswordFieldType(element, { ignoreConnect = false } = {}) { + if (!HTMLInputElement.isInstance(element)) { + return false; + } + + if (!element.isConnected && !ignoreConnect) { + // If the element isn't connected then it isn't visible to the user so + // shouldn't be considered. It must have been connected in the past. + return false; + } + + if (!element.hasBeenTypePassword) { + return false; + } + + // Ensure the element is of a type that could have autocomplete. + // These include the types with user-editable values. If not, even if it used to be + // a type=password, we can't treat it as a password input now + let acInfo = element.getAutocompleteInfo(); + if (!acInfo) { + return false; + } + + return true; + }, + + /** + * Checks if a field type is username compatible. + * + * @param {Element} element + * the field we want to check. + * @param {Object} options + * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected + * of the element. + * + * @returns {Boolean} true if the field type is one + * of the username types. + */ + isUsernameFieldType(element, { ignoreConnect = false } = {}) { + if (!HTMLInputElement.isInstance(element)) { + return false; + } + + if (!element.isConnected && !ignoreConnect) { + // If the element isn't connected then it isn't visible to the user so + // shouldn't be considered. It must have been connected in the past. + return false; + } + + if (element.hasBeenTypePassword) { + return false; + } + + if (!Logic.inputTypeIsCompatibleWithUsername(element)) { + return false; + } + + let acFieldName = element.getAutocompleteInfo().fieldName; + if ( + !( + acFieldName == "username" || + // Bug 1540154: Some sites use tel/email on their username fields. + acFieldName == "email" || + acFieldName == "tel" || + acFieldName == "tel-national" || + acFieldName == "off" || + acFieldName == "on" || + acFieldName == "" + ) + ) { + return false; + } + return true; + }, + + /** + * Infer whether a form is a sign-in form by searching keywords + * in its attributes + * + * @param {Element} element + * the form we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredLoginForm(formElement) { + // This is copied from 'loginFormAttrRegex' in NewPasswordModel.jsm + const loginExpr = + /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i; + + if (Logic.elementAttrsMatchRegex(formElement, loginExpr)) { + return true; + } + + return false; + }, + + /** + * Infer whether an input field is a username field by searching + * 'username' keyword in its attributes + * + * @param {Element} element + * the field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredUsernameField(element) { + const expr = /username/i; + + let ac = element.getAutocompleteInfo()?.fieldName; + if (ac && ac == "username") { + return true; + } + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * Search for keywords that indicates the input field is not likely a + * field of a username login form. + * + * @param {Element} element + * the input field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredNonUsernameField(element) { + const expr = /search|code/i; + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * Infer whether an input field is an email field by searching + * 'email' keyword in its attributes. + * + * @param {Element} element + * the field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredEmailField(element) { + const expr = /email|邮箱/i; + + if (element.type == "email") { + return true; + } + + let ac = element.getAutocompleteInfo()?.fieldName; + if (ac && ac == "email") { + return true; + } + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * For each login, add the login to the password manager if a similar one + * doesn't already exist. Merge it otherwise with the similar existing ones. + * + * @param {Object[]} loginDatas - For each login, the data that needs to be added. + * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data. + */ + async maybeImportLogins(loginDatas) { + this.importing = true; + try { + const processor = new ImportRowProcessor(); + for (let rawLoginData of loginDatas) { + // Do some sanitization on a clone of the loginData. + let loginData = ChromeUtils.shallowClone(rawLoginData); + if (processor.checkNonUniqueGuidError(loginData)) { + continue; + } + if (processor.checkMissingMandatoryFieldsError(loginData)) { + continue; + } + processor.cleanupActionAndRealmFields(loginData); + if (await processor.checkExistingEntry(loginData)) { + continue; + } + let login = processor.createNewLogin(loginData); + if (processor.checkLoginValuesError(login, loginData)) { + continue; + } + if (processor.checkConflictingOriginWithPreviousRows(login)) { + continue; + } + if (processor.checkConflictingWithExistingLogins(login)) { + continue; + } + processor.addLoginToSummary(login, "added"); + } + return await processor.processLoginsAndBuildSummary(); + } finally { + this.importing = false; + + Services.obs.notifyObservers(null, "passwordmgr-reload-all"); + } + }, + + /** + * Convert an array of nsILoginInfo to vanilla JS objects suitable for + * sending over IPC. Avoid using this in other cases. + * + * NB: All members of nsILoginInfo (not nsILoginMetaInfo) are strings. + */ + loginsToVanillaObjects(logins) { + return logins.map(this.loginToVanillaObject); + }, + + /** + * Same as above, but for a single login. + */ + loginToVanillaObject(login) { + let obj = {}; + for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) { + if (typeof login[i] !== "function") { + obj[i] = login[i]; + } + } + return obj; + }, + + /** + * Convert an object received from IPC into an nsILoginInfo (with guid). + */ + vanillaObjectToLogin(login) { + let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + formLogin.init( + login.origin, + login.formActionOrigin, + login.httpRealm, + login.username, + login.password, + login.usernameField, + login.passwordField + ); + + formLogin.QueryInterface(Ci.nsILoginMetaInfo); + for (let prop of [ + "guid", + "timeCreated", + "timeLastUsed", + "timePasswordChanged", + "timesUsed", + ]) { + formLogin[prop] = login[prop]; + } + return formLogin; + }, + + /** + * As above, but for an array of objects. + */ + vanillaObjectsToLogins(vanillaObjects) { + const logins = []; + for (const vanillaObject of vanillaObjects) { + logins.push(this.vanillaObjectToLogin(vanillaObject)); + } + return logins; + }, + + /** + * Returns true if the user has a primary password set and false otherwise. + */ + isPrimaryPasswordSet() { + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + let token = tokenDB.getInternalKeyToken(); + return token.hasPassword; + }, + + /** + * Shows the Primary Password prompt if enabled, or the + * OS auth dialog otherwise. + * @param {Element} browser + * The <browser> that the prompt should be shown on + * @param OSReauthEnabled Boolean indicating if OS reauth should be tried + * @param expirationTime Optional timestamp indicating next required re-authentication + * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used. + * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used. + */ + async requestReauth( + browser, + OSReauthEnabled, + expirationTime, + messageText, + captionText + ) { + let isAuthorized = false; + let telemetryEvent; + + // This does no harm if primary password isn't set. + let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance( + Ci.nsIPK11TokenDB + ); + let token = tokendb.getInternalKeyToken(); + + // Do we have a recent authorization? + if (expirationTime && Date.now() < expirationTime) { + isAuthorized = true; + telemetryEvent = { + object: token.hasPassword ? "master_password" : "os_auth", + method: "reauthenticate", + value: "success_no_prompt", + }; + return { + isAuthorized, + telemetryEvent, + }; + } + + // Default to true if there is no primary password and OS reauth is not available + if (!token.hasPassword && !OSReauthEnabled) { + isAuthorized = true; + telemetryEvent = { + object: "os_auth", + method: "reauthenticate", + value: "success_disabled", + }; + return { + isAuthorized, + telemetryEvent, + }; + } + // Use the OS auth dialog if there is no primary password + if (!token.hasPassword && OSReauthEnabled) { + let result = await lazy.OSKeyStore.ensureLoggedIn( + messageText, + captionText, + browser.ownerGlobal, + false + ); + isAuthorized = result.authenticated; + telemetryEvent = { + object: "os_auth", + method: "reauthenticate", + value: result.auth_details, + extra: result.auth_details_extra, + }; + return { + isAuthorized, + telemetryEvent, + }; + } + // We'll attempt to re-auth via Primary Password, force a log-out + token.checkPassword(""); + + // If a primary password prompt is already open, just exit early and return false. + // The user can re-trigger it after responding to the already open dialog. + if (Services.logins.uiBusy) { + isAuthorized = false; + return { + isAuthorized, + telemetryEvent, + }; + } + + // So there's a primary password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl). + try { + // Relogin and ask for the primary password. + token.login(true); // 'true' means always prompt for token password. User will be prompted until + // clicking 'Cancel' or entering the correct password. + } catch (e) { + // An exception will be thrown if the user cancels the login prompt dialog. + // User is also logged out of Software Security Device. + } + isAuthorized = token.isLoggedIn(); + telemetryEvent = { + object: "master_password", + method: "reauthenticate", + value: isAuthorized ? "success" : "fail", + }; + return { + isAuthorized, + telemetryEvent, + }; + }, + + /** + * Send a notification when stored data is changed. + */ + notifyStorageChanged(changeType, data) { + if (this.importing) { + return; + } + + let dataObject = data; + // Can't pass a raw JS string or array though notifyObservers(). :-( + if (Array.isArray(data)) { + dataObject = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + for (let i = 0; i < data.length; i++) { + dataObject.appendElement(data[i]); + } + } else if (typeof data == "string") { + dataObject = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + dataObject.data = data; + } + Services.obs.notifyObservers( + dataObject, + "passwordmgr-storage-changed", + changeType + ); + }, + + isUserFacingLogin(login) { + return login.origin != "chrome://FirefoxAccounts"; // FXA_PWDMGR_HOST + }, + + async getAllUserFacingLogins() { + try { + let logins = await Services.logins.getAllLoginsAsync(); + return logins.filter(this.isUserFacingLogin); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + // If the user cancels the MP prompt then return no logins. + return []; + } + throw e; + } + }, + + createLoginAlreadyExistsError(guid) { + // The GUID is stored in an nsISupportsString here because we cannot pass + // raw JS objects within Components.Exception due to bug 743121. + let guidSupportsString = Cc[ + "@mozilla.org/supports-string;1" + ].createInstance(Ci.nsISupportsString); + guidSupportsString.data = guid; + return Components.Exception("This login already exists.", { + data: guidSupportsString, + }); + }, + + /** + * Determine the <browser> that a prompt should be shown on. + * + * Some sites pop up a temporary login window, which disappears + * upon submission of credentials. We want to put the notification + * prompt in the opener window if this seems to be happening. + * + * @param {Element} browser + * The <browser> that a prompt was triggered for + * @returns {Element} The <browser> that the prompt should be shown on, + * which could be in a different window. + */ + getBrowserForPrompt(browser) { + let chromeWindow = browser.ownerGlobal; + let openerBrowsingContext = browser.browsingContext.opener; + let openerBrowser = openerBrowsingContext + ? openerBrowsingContext.top.embedderElement + : null; + if (openerBrowser) { + let chromeDoc = chromeWindow.document.documentElement; + + // Check to see if the current window was opened with chrome + // disabled, and if so use the opener window. But if the window + // has been used to visit other pages (ie, has a history), + // assume it'll stick around and *don't* use the opener. + if (chromeDoc.getAttribute("chromehidden") && !browser.canGoBack) { + lazy.log.debug("Using opener window for prompt."); + return openerBrowser; + } + } + + return browser; + }, +}; + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let processName = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT + ? "Main" + : "Content"; + return LoginHelper.createLogger(`LoginHelper(${processName})`); +}); + +LoginHelper.init(); + +export class OptInFeature { + implementation; + #offered; + #enabled; + #disabled; + #pref; + + static PREF_AVAILABLE_VALUE = "available"; + static PREF_OFFERED_VALUE = "offered"; + static PREF_ENABLED_VALUE = "enabled"; + static PREF_DISABLED_VALUE = "disabled"; + + constructor(offered, enabled, disabled, pref) { + this.#pref = pref; + this.#offered = offered; + this.#enabled = enabled; + this.#disabled = disabled; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "implementationPref", + pref, + undefined, + (_preference, _prevValue, _newValue) => this.#updateImplementation() + ); + + this.#updateImplementation(); + } + + get #currentPrefValue() { + // Read pref directly instead of relying on this.implementationPref because + // there is an implementationPref value update lag that affects tests. + return Services.prefs.getStringPref(this.#pref, undefined); + } + + get isAvailable() { + return [ + OptInFeature.PREF_AVAILABLE_VALUE, + OptInFeature.PREF_OFFERED_VALUE, + OptInFeature.PREF_ENABLED_VALUE, + OptInFeature.PREF_DISABLED_VALUE, + ].includes(this.#currentPrefValue); + } + + get isEnabled() { + return this.#currentPrefValue == OptInFeature.PREF_ENABLED_VALUE; + } + + get isDisabled() { + return this.#currentPrefValue == OptInFeature.PREF_DISABLED_VALUE; + } + + markAsAvailable() { + this.#markAs(OptInFeature.PREF_AVAILABLE_VALUE); + } + + markAsOffered() { + this.#markAs(OptInFeature.PREF_OFFERED_VALUE); + } + + markAsEnabled() { + this.#markAs(OptInFeature.PREF_ENABLED_VALUE); + } + + markAsDisabled() { + this.#markAs(OptInFeature.PREF_DISABLED_VALUE); + } + + #markAs(value) { + Services.prefs.setStringPref(this.#pref, value); + } + + #updateImplementation() { + switch (this.implementationPref) { + case OptInFeature.PREF_ENABLED_VALUE: + this.implementation = new this.#enabled(); + break; + case OptInFeature.PREF_AVAILABLE_VALUE: + case OptInFeature.PREF_OFFERED_VALUE: + this.implementation = new this.#offered(); + break; + case OptInFeature.PREF_DISABLED_VALUE: + default: + this.implementation = new this.#disabled(); + break; + } + } +} |