diff options
Diffstat (limited to 'components/passwordmgr/src/LoginHelper.jsm')
-rw-r--r-- | components/passwordmgr/src/LoginHelper.jsm | 725 |
1 files changed, 725 insertions, 0 deletions
diff --git a/components/passwordmgr/src/LoginHelper.jsm b/components/passwordmgr/src/LoginHelper.jsm new file mode 100644 index 000000000..c6cd40915 --- /dev/null +++ b/components/passwordmgr/src/LoginHelper.jsm @@ -0,0 +1,725 @@ +/* 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. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "LoginHelper", +]; + +// Globals + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +// LoginHelper + +/** + * Contains functions shared by different Login Manager components. + */ +this.LoginHelper = { + /** + * Warning: these only update if a logger was created. + */ + debug: Services.prefs.getBoolPref("signon.debug"), + formlessCaptureEnabled: Services.prefs.getBoolPref("signon.formlessCapture.enabled"), + schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"), + insecureAutofill: Services.prefs.getBoolPref("signon.autofillForms.http"), + showInsecureFieldWarning: Services.prefs.getBoolPref("security.insecure_field_warning.contextual.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 ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI; + let consoleOptions = { + maxLogLevel: getMaxLogLevel(), + prefix: aLogPrefix, + }; + let logger = new ConsoleAPI(consoleOptions); + + // Watch for pref changes and update this.debug and the maxLogLevel for created loggers + Services.prefs.addObserver("signon.", () => { + this.debug = Services.prefs.getBoolPref("signon.debug"); + this.formlessCaptureEnabled = Services.prefs.getBoolPref("signon.formlessCapture.enabled"); + this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades"); + this.insecureAutofill = Services.prefs.getBoolPref("signon.autofillForms.http"); + logger.maxLogLevel = getMaxLogLevel(); + }, false); + + Services.prefs.addObserver("security.insecure_field_warning.", () => { + this.showInsecureFieldWarning = Services.prefs.getBoolPref("security.insecure_field_warning.contextual.enabled"); + }, false); + + 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 hostnames that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkHostnameValue(aHostname) { + // Nulls are invalid, as they don't round-trip well. Newlines are also + // invalid for any field stored as plaintext, and a hostname made of a + // single dot cannot be stored in the legacy format. + if (aHostname == "." || + aHostname.indexOf("\r") != -1 || + aHostname.indexOf("\n") != -1 || + aHostname.indexOf("\0") != -1) { + throw new Error("Invalid hostname"); + } + }, + + /** + * 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 logins that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkLoginValues(aLogin) { + function badCharacterPresent(l, c) { + return ((l.formSubmitURL && l.formSubmitURL.indexOf(c) != -1) || + (l.httpRealm && l.httpRealm.indexOf(c) != -1) || + l.hostname.indexOf(c) != -1 || + l.usernameField.indexOf(c) != -1 || + l.passwordField.indexOf(c) != -1); + } + + // 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"); + } + + // 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.indexOf("\0") != -1 || + aLogin.password.indexOf("\0") != -1) { + 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.formSubmitURL == ".") { + throw new Error("login values can't be periods"); + } + + // A hostname with "\ \(" won't roundtrip. + // eg host="foo (", realm="bar" --> "foo ( (bar)" + // vs host="foo", realm=" (bar" --> "foo ( (bar)" + if (aLogin.hostname.indexOf(" (") != -1) { + throw new Error("bad parens in hostname"); + } + }, + + /** + * 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 `count` argument and property bags when calling + * Services.logins.searchLogins from JS. + * + * @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} aLoginOrigin - An origin value from a stored login's + * hostname or formSubmitURL 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, + }) { + if (aLoginOrigin == aSearchOrigin) { + return true; + } + + if (!aOptions) { + return false; + } + + if (aOptions.schemeUpgrades) { + try { + let loginURI = Services.io.newURI(aLoginOrigin, null, null); + let searchURI = Services.io.newURI(aSearchOrigin, null, null); + if (loginURI.scheme == "http" && searchURI.scheme == "https" && + loginURI.hostPort == searchURI.hostPort) { + return true; + } + } catch (ex) { + // newURI will throw for some values + 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 hostname1URI = Services.io.newURI(aLogin1.hostname, null, null); + let hostname2URI = Services.io.newURI(aLogin2.hostname, null, null); + if (hostname1URI.hostPort != hostname2URI.hostPort) + return false; + + if (aLogin1.formSubmitURL != "" && aLogin2.formSubmitURL != "" && + Services.io.newURI(aLogin1.formSubmitURL, null, null).hostPort != + Services.io.newURI(aLogin2.formSubmitURL, null, null).hostPort) + return false; + } else { + if (aLogin1.hostname != aLogin2.hostname) + return false; + + // If either formSubmitURL is blank (but not null), then match. + if (aLogin1.formSubmitURL != "" && aLogin2.formSubmitURL != "" && + aLogin1.formSubmitURL != aLogin2.formSubmitURL) + 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 aOldStoredLogin + * Existing nsILoginInfo object to modify. + * @param aNewLoginData + * The new login values, either as nsILoginInfo or nsIProperyBag. + * + * @return The newly created nsILoginInfo object. + * + * @throws String 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.hostname, + aNewLoginData.formSubmitURL, aNewLoginData.httpRealm, + aNewLoginData.username, aNewLoginData.password, + aNewLoginData.usernameField, aNewLoginData.passwordField); + 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(); + } + } + + let propEnum = aNewLoginData.enumerator; + while (propEnum.hasMoreElements()) { + let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty); + switch (prop.name) { + // nsILoginInfo + case "hostname": + case "httpRealm": + case "formSubmitURL": + case "username": + case "password": + case "usernameField": + case "passwordField": + // nsILoginMetaInfo + 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.hostname == null || newLogin.hostname.length == 0) { + throw new Error("Can't add a login with a null or empty hostname."); + } + + // 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 == 0) { + throw new Error("Can't add a login with a null or empty password."); + } + + if (newLogin.formSubmitURL || newLogin.formSubmitURL == "") { + // 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 formSubmitURL."); + } + } else if (newLogin.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (newLogin.formSubmitURL != null) { + throw new Error("Can't add a login with both a httpRealm and formSubmitURL."); + } + } else { + // Need one or the other! + throw new Error("Can't add a login without a httpRealm or formSubmitURL."); + } + + // Throws if there are bogus values. + this.checkLoginValues(newLogin); + + return newLogin; + }, + + /** + * 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. + * + * @returns {nsILoginInfo[]} list of unique logins. + */ + dedupeLogins(logins, uniqueKeys = ["username", "password"], + resolveBy = ["timeLastUsed"], + preferredOrigin = undefined) { + const KEY_DELIMITER = ":"; + + if (!preferredOrigin && resolveBy.includes("scheme")) { + throw new Error("dedupeLogins: `preferredOrigin` is required in order to " + + "prefer schemes which match it."); + } + + let preferredOriginScheme; + if (preferredOrigin) { + try { + preferredOriginScheme = Services.io.newURI(preferredOrigin, null, null).scheme; + } catch (ex) { + // Handle strings that aren't valid URIs + } + } + + if (!preferredOriginScheme && resolveBy.includes("scheme")) { + log.warn("dedupeLogins: 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(); + + // Generate a unique key string from a login. + function getKey(login, uniqueKeys) { + return uniqueKeys.reduce((prev, key) => prev + KEY_DELIMITER + login[key], ""); + } + + /** + * @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 == 0) { + // If there is no preference, prefer the existing login. + return false; + } + + for (let preference of resolveBy) { + switch (preference) { + case "scheme": { + if (!preferredOriginScheme) { + break; + } + + try { + // Only `hostname` is currently considered + let existingLoginURI = Services.io.newURI(existingLogin.hostname, null, null); + let loginURI = Services.io.newURI(login.hostname, null, null); + // 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 (ex) { + // Some URLs aren't valid nsIURI + log.debug("dedupeLogins/shouldReplaceExisting: Error comparing schemes:", + existingLogin.hostname, login.hostname, + "preferredOrigin:", preferredOrigin, ex); + } + 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 = getKey(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 {string} [filterString=""] + * the filterString parameter to pass to the login manager dialog + */ + openPasswordManager(window, filterString = "") { + let win = Services.wm.getMostRecentWindow("Toolkit:PasswordManager"); + if (win) { + win.setFilter(filterString); + win.focus(); + } else { + window.openDialog("chrome://passwordmgr/content/passwordManager.xul", + "Toolkit:PasswordManager", "", + {filterString : filterString}); + } + }, + + /** + * Checks if a field type is username compatible. + * + * @param {Element} element + * the field we want to check. + * + * @returns {Boolean} true if the field type is one + * of the username types. + */ + isUsernameFieldType(element) { + if (!(element instanceof Ci.nsIDOMHTMLInputElement)) + return false; + + let fieldType = (element.hasAttribute("type") ? + element.getAttribute("type").toLowerCase() : + element.type); + if (fieldType == "text" || + fieldType == "email" || + fieldType == "url" || + fieldType == "tel" || + fieldType == "number") { + return true; + } + return false; + }, + + /** + * 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} loginData - the data about the login that needs to be added. + * @returns {nsILoginInfo} the newly added login, or null if no login was added. + * Note that we will also return null if an existing login + * was modified. + */ + maybeImportLogin(loginData) { + // create a new login + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo); + login.init(loginData.hostname, + loginData.formSubmitURL || (typeof(loginData.httpRealm) == "string" ? null : ""), + typeof(loginData.httpRealm) == "string" ? loginData.httpRealm : null, + 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; + // While here we're passing formSubmitURL 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.hostname, + login.formSubmitURL, + 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 */))) { + return null; + } + // 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); + Services.logins.modifyLogin(existingLogin, propBag); + } + } + } + // if the new login is an update or is older than an exiting login, don't add it. + if (foundMatchingLogin) { + return null; + } + return Services.logins.addLogin(login); + }, + + /** + * Convert an array of nsILoginInfo to vanilla JS objects suitable for + * sending over IPC. + * + * NB: All members of nsILoginInfo and 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.hostname, login.formSubmitURL, + 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(logins) { + return logins.map(this.vanillaObjectToLogin); + }, + + removeLegacySignonFiles() { + const {Constants, Path, File} = Cu.import("resource://gre/modules/osfile.jsm").OS; + + const profileDir = Constants.Path.profileDir; + const defaultSignonFilePrefs = new Map([ + ["signon.SignonFileName", "signons.txt"], + ["signon.SignonFileName2", "signons2.txt"], + ["signon.SignonFileName3", "signons3.txt"] + ]); + const toDeletes = new Set(); + + for (let [pref, val] of defaultSignonFilePrefs.entries()) { + toDeletes.add(Path.join(profileDir, val)); + + try { + let signonFile = Services.prefs.getCharPref(pref); + + toDeletes.add(Path.join(profileDir, signonFile)); + Services.prefs.clearUserPref(pref); + } catch (e) {} + } + + for (let file of toDeletes) { + File.remove(file); + } + }, + + /** + * Returns true if the user has a master password set and false otherwise. + */ + isMasterPasswordSet() { + let secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"]. + getService(Ci.nsIPKCS11ModuleDB); + let slot = secmodDB.findSlotByName(""); + if (!slot) { + return false; + } + let hasMP = slot.status != Ci.nsIPKCS11Slot.SLOT_UNINITIALIZED && + slot.status != Ci.nsIPKCS11Slot.SLOT_READY; + return hasMP; + }, + + /** + * Send a notification when stored data is changed. + */ + notifyStorageChanged(changeType, data) { + 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], false); + } + } 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); + } +}; + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginHelper"); + return logger; +}); |