/* 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/. */ /** * LoginManagerStorage implementation for the JSON back-end. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs", FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs", LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", LoginStore: "resource://gre/modules/LoginStore.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => { let logger = lazy.LoginHelper.createLogger("storage-json"); return logger; }); const SYNCABLE_LOGIN_FIELDS = [ // `nsILoginInfo` fields. "hostname", "formSubmitURL", "httpRealm", "username", "password", "usernameField", "passwordField", // `nsILoginMetaInfo` fields. "timeCreated", "timePasswordChanged", ]; // Compares two logins to determine if their syncable fields changed. The login // manager fires `modifyLogin` for changes to all fields, including ones we // don't sync. In particular, `timeLastUsed` changes shouldn't mark the login // for upload; otherwise, we might overwrite changed passwords before they're // downloaded (bug 973166). function isSyncableChange(oldLogin, newLogin) { oldLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); newLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); return SYNCABLE_LOGIN_FIELDS.some(prop => oldLogin[prop] != newLogin[prop]); } // Returns true if the argument is for the FxA login. function isFXAHost(login) { return login.hostname == lazy.FXA_PWDMGR_HOST; } export class LoginManagerStorage_json { constructor() { this.__crypto = null; // nsILoginManagerCrypto service this.__decryptedPotentiallyVulnerablePasswords = null; } get _crypto() { if (!this.__crypto) { this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService( Ci.nsILoginManagerCrypto ); } return this.__crypto; } // Lazily decrypted cache of potentially vulnerable passwords. get decryptedPotentiallyVulnerablePasswords() { if (!this.__decryptedPotentiallyVulnerablePasswords) { this._store.ensureDataReady(); this.__decryptedPotentiallyVulnerablePasswords = []; for (const potentiallyVulnerablePassword of this._store.data .potentiallyVulnerablePasswords) { const decryptedPotentiallyVulnerablePassword = this._crypto.decrypt( potentiallyVulnerablePassword.encryptedPassword ); this.__decryptedPotentiallyVulnerablePasswords.push( decryptedPotentiallyVulnerablePassword ); } } return this.__decryptedPotentiallyVulnerablePasswords; } initialize() { try { // Force initialization of the crypto module. // See bug 717490 comment 17. this._crypto; let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; // Set the reference to LoginStore synchronously. let jsonPath = PathUtils.join(profileDir, "logins.json"); let backupPath = ""; let loginsBackupEnabled = Services.prefs.getBoolPref( "signon.backup.enabled" ); if (loginsBackupEnabled) { backupPath = PathUtils.join(profileDir, "logins-backup.json"); } // Note that LoginStore is based on JSONFile which brings its own // shutdown blocker to finalize properly, so we do not need one here. this._store = new lazy.LoginStore(jsonPath, backupPath); // The ProfileDataUpgrader can possibly set this pref. As we don't know // whether that has already happened, or will still happen, we need to add // a pref observer. Services.prefs.addObserver( "signon.reencryptionNeeded", this.#observeReencryptionNeeded.bind(this) ); this.#observeReencryptionNeeded(); return (async () => { // Load the data asynchronously. lazy.logger.log(`Opening database at ${this._store.path}.`); await this._store.load(); })().catch(console.error); } catch (e) { lazy.logger.log(`Initialization failed ${e.name}.`); throw new Error("Initialization failed"); } } /** * Internal method used by tests only. It is called before replacing * this storage module with a new instance. It avoids to finalize the * underlying DeferredTask as it is still needed for the next tests. */ testSaveForReplace() { this._store._saver.disarm(); return this._store._save(); } /** * Returns the "sync id" used by Sync to know whether the store is current with * respect to the sync servers. It is stored encrypted, but only so we * can detect failure to decrypt (for example, a "reset" of the primary * password will leave all logins alone, but they will fail to decrypt. We * also want this metadata to be unavailable in that scenario) * * Returns null if the data doesn't exist or if the data can't be * decrypted (including if the primary-password prompt is cancelled). This is * OK for Sync as it can't even begin syncing if the primary-password is * locked as the sync encrytion keys are stored in this login manager. */ async getSyncID() { await this._store.load(); if (!this._store.data.sync) { return null; } let raw = this._store.data.sync.syncID; try { return raw ? this._crypto.decrypt(raw) : null; } catch (e) { if (e.result == Cr.NS_ERROR_FAILURE) { lazy.logger.log("Could not decrypt the syncID - returning null."); return null; } // any other errors get re-thrown. throw e; } } async setSyncID(syncID) { await this._store.load(); if (!this._store.data.sync) { this._store.data.sync = {}; } this._store.data.sync.syncID = syncID ? this._crypto.encrypt(syncID) : null; this._store.saveSoon(); } async getLastSync() { await this._store.load(); if (!this._store.data.sync) { return 0; } return this._store.data.sync.lastSync || 0.0; } async setLastSync(timestamp) { await this._store.load(); if (!this._store.data.sync) { this._store.data.sync = {}; } this._store.data.sync.lastSync = timestamp; this._store.saveSoon(); } #incrementSyncCounter(login) { login.syncCounter++; } async resetSyncCounter(guid, value) { this._store.ensureDataReady(); // This will also find deleted items. let login = this._store.data.logins.find(login => login.guid == guid); if (login?.syncCounter > 0) { login.syncCounter = Math.max(0, login.syncCounter - value); login.everSynced = true; } this._store.saveSoon(); } // Returns false if the login has marked as deleted or doesn't exist. #loginIsDeleted(guid) { let login = this._store.data.logins.find(l => l.guid == guid); return !!login?.deleted; } async loginIsDeletedAsync(guid) { let result = this.#loginIsDeleted(guid); // Emulate being async: return Promise.resolve(result); } // Synchronuously stores encrypted login, returns login clone with upserted // uuid and updated timestamps async #addLogin(login) { this._store.ensureDataReady(); // Throws if there are bogus values. lazy.LoginHelper.checkLoginValues(login); // Clone the login, so we don't modify the caller's object. let loginClone = login.clone(); // Initialize the nsILoginMetaInfo fields, unless the caller gave us values loginClone.QueryInterface(Ci.nsILoginMetaInfo); if (loginClone.guid) { let guid = loginClone.guid; if (!this._isGuidUnique(guid)) { // We have an existing GUID, but it's possible that entry is unable // to be decrypted - if that's the case we remove the existing one // and allow this one to be added. let existing = this._searchLogins({ guid })[0]; if (this._decryptLogins(existing).length) { // Existing item is good, so it's an error to try and re-add it. throw new Error("specified GUID already exists"); } // find and remove the existing bad entry. let foundIndex = this._store.data.logins.findIndex(l => l.guid == guid); if (foundIndex == -1) { throw new Error("can't find a matching GUID to remove"); } this._store.data.logins.splice(foundIndex, 1); } } else { loginClone.guid = Services.uuid.generateUUID().toString(); } // Set timestamps let currentTime = Date.now(); if (!loginClone.timeCreated) { loginClone.timeCreated = currentTime; } if (!loginClone.timeLastUsed) { loginClone.timeLastUsed = currentTime; } if (!loginClone.timePasswordChanged) { loginClone.timePasswordChanged = currentTime; } if (!loginClone.timesUsed) { loginClone.timesUsed = 1; } // If the everSynced is already set, then this login is an incoming // sync record, so there is no need to mark this as needed to be synced. if (!loginClone.everSynced && !isFXAHost(loginClone)) { this.#incrementSyncCounter(loginClone); } this._store.data.logins.push({ id: this._store.data.nextId++, hostname: loginClone.origin, httpRealm: loginClone.httpRealm, formSubmitURL: loginClone.formActionOrigin, usernameField: loginClone.usernameField, passwordField: loginClone.passwordField, encryptedUsername: loginClone.username, encryptedPassword: loginClone.password, guid: loginClone.guid, encType: this._crypto.defaultEncType, timeCreated: loginClone.timeCreated, timeLastUsed: loginClone.timeLastUsed, timePasswordChanged: loginClone.timePasswordChanged, timesUsed: loginClone.timesUsed, timeLastBreachAlertDismissed: loginClone.timeLastBreachAlertDismissed, syncCounter: loginClone.syncCounter, everSynced: loginClone.everSynced, encryptedUnknownFields: loginClone.unknownFields, }); this._store.saveSoon(); Glean.pwmgr.numSavedPasswords.set(await this.countLoginsAsync("", "", "")); return loginClone; } async addLoginsAsync(logins, continueOnDuplicates = false) { if (logins.length === 0) { return logins; } const encryptedLogins = await this.#encryptLogins(logins); const resultLogins = []; for (const [login, encryptedLogin] of encryptedLogins) { // check for duplicates let loginData = { origin: login.origin, formActionOrigin: login.formActionOrigin, httpRealm: login.httpRealm, }; const existingLogins = await Services.logins.searchLoginsAsync(loginData); const matchingLogin = existingLogins.find(l => login.matches(l, true)); if (matchingLogin) { if (continueOnDuplicates) { continue; } else { throw lazy.LoginHelper.createLoginAlreadyExistsError( matchingLogin.guid ); } } const resultLogin = await this.#addLogin(encryptedLogin); // restore unencrypted username and password for use in `addLogin` event // and return value resultLogin.username = login.username; resultLogin.password = login.password; // Send a notification that a login was added. lazy.LoginHelper.notifyStorageChanged("addLogin", resultLogin); resultLogins.push(resultLogin); } return resultLogins; } /** * @deprecated Use removeLoginAsync instead. * Will be removed in Bug 2022270. */ removeLogin(login, fromSync) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.removeLogin() is deprecated. Use removeLoginAsync(). Will be removed in Bug 2022270." ); this._store.ensureDataReady(); let [idToDelete, storedLogin] = this._getIdForLogin(login); if (!idToDelete) { throw new Error("No matching logins"); } let foundIndex = this._store.data.logins.findIndex(l => l.id == idToDelete); if (foundIndex != -1) { let login = this._store.data.logins[foundIndex]; if (!login.deleted) { if (fromSync) { this.#replaceLoginWithTombstone(login); } else if (login.everSynced) { // The login has been synced, so mark it as deleted. this.#incrementSyncCounter(login); this.#replaceLoginWithTombstone(login); } else { // The login was never synced, so just remove it from the data. this._store.data.logins.splice(foundIndex, 1); } this._store.saveSoon(); } } Glean.pwmgr.numSavedPasswords.set(this.countLogins("", "", "")); lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin); } async removeLoginAsync(login, fromSync) { this._store.ensureDataReady(); let [idToDelete, storedLogin] = this._getIdForLogin(login); if (!idToDelete) { throw new Error("No matching logins"); } let foundIndex = this._store.data.logins.findIndex(l => l.id == idToDelete); if (foundIndex != -1) { let existingLogin = this._store.data.logins[foundIndex]; if (!existingLogin.deleted) { if (fromSync) { this.#replaceLoginWithTombstone(existingLogin); } else if (existingLogin.everSynced) { // The login has been synced, so mark it as deleted. this.#incrementSyncCounter(existingLogin); this.#replaceLoginWithTombstone(existingLogin); } else { // The login was never synced, so just remove it from the data. this._store.data.logins.splice(foundIndex, 1); } this._store.saveSoon(); } } Glean.pwmgr.numSavedPasswords.set(await this.countLoginsAsync("", "", "")); lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin); } /** * @deprecated Use modifyLoginAsync instead. * Will be removed in Bug 2022270. */ modifyLogin(oldLogin, newLoginData, fromSync) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.modifyLogin() is deprecated. Use modifyLoginAsync(). Will be removed in Bug 2022270." ); this._store.ensureDataReady(); let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin); if (!idToModify) { throw new Error("No matching logins"); } let newLogin = lazy.LoginHelper.buildModifiedLogin( oldStoredLogin, newLoginData ); // Check if the new GUID is duplicate. if ( newLogin.guid != oldStoredLogin.guid && !this._isGuidUnique(newLogin.guid) ) { throw new Error("specified GUID already exists"); } // Look for an existing entry in case key properties changed. if (!newLogin.matches(oldLogin, true)) { let loginData = { origin: newLogin.origin, formActionOrigin: newLogin.formActionOrigin, httpRealm: newLogin.httpRealm, }; let logins = this.searchLogins( lazy.LoginHelper.newPropertyBag(loginData) ); let matchingLogin = logins.find(login => newLogin.matches(login, true)); if (matchingLogin) { throw lazy.LoginHelper.createLoginAlreadyExistsError( matchingLogin.guid ); } } // Don't sync changes to the accounts password or when changes were only // made to fields that should not be synced. if ( !fromSync && !isFXAHost(newLogin) && isSyncableChange(oldLogin, newLogin) ) { this.#incrementSyncCounter(newLogin); } // Get the encrypted value of the username and password. let [encUsername, encPassword, encType, encUnknownFields] = this._encryptLogin(newLogin); for (let loginItem of this._store.data.logins) { if (loginItem.id == idToModify && !loginItem.deleted) { loginItem.hostname = newLogin.origin; loginItem.httpRealm = newLogin.httpRealm; loginItem.formSubmitURL = newLogin.formActionOrigin; loginItem.usernameField = newLogin.usernameField; loginItem.passwordField = newLogin.passwordField; loginItem.encryptedUsername = encUsername; loginItem.encryptedPassword = encPassword; loginItem.guid = newLogin.guid; loginItem.encType = encType; loginItem.timeCreated = newLogin.timeCreated; loginItem.timeLastUsed = newLogin.timeLastUsed; loginItem.timePasswordChanged = newLogin.timePasswordChanged; loginItem.timesUsed = newLogin.timesUsed; loginItem.timeLastBreachAlertDismissed = newLogin.timeLastBreachAlertDismissed; loginItem.encryptedUnknownFields = encUnknownFields; loginItem.syncCounter = newLogin.syncCounter; this._store.saveSoon(); break; } } lazy.LoginHelper.notifyStorageChanged("modifyLogin", [ oldStoredLogin, newLogin, ]); } async modifyLoginAsync(oldLogin, newLoginData, fromSync) { this._store.ensureDataReady(); let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin); if (!idToModify) { throw new Error("No matching logins"); } let newLogin = lazy.LoginHelper.buildModifiedLogin( oldStoredLogin, newLoginData ); // Check if the new GUID is duplicate. if ( newLogin.guid != oldStoredLogin.guid && !this._isGuidUnique(newLogin.guid) ) { throw new Error("specified GUID already exists"); } // Look for an existing entry in case key properties changed. if (!newLogin.matches(oldLogin, true)) { const matchData = {}; for (const field of ["origin", "formActionOrigin", "httpRealm"]) { if (newLogin[field] !== "") { matchData[field] = newLogin[field]; } } const logins = await this.searchLoginsAsync(matchData); let matchingLogin = logins.find(login => newLogin.matches(login, true)); if (matchingLogin) { throw lazy.LoginHelper.createLoginAlreadyExistsError( matchingLogin.guid ); } } // Don't sync changes to the accounts password or when changes were only // made to fields that should not be synced. if ( !fromSync && !isFXAHost(newLogin) && isSyncableChange(oldLogin, newLogin) ) { this.#incrementSyncCounter(newLogin); } // Get the encrypted value of the username and password. let [encUsername, encPassword, encType, encUnknownFields] = this._encryptLogin(newLogin); for (let loginItem of this._store.data.logins) { if (loginItem.id == idToModify && !loginItem.deleted) { loginItem.hostname = newLogin.origin; loginItem.httpRealm = newLogin.httpRealm; loginItem.formSubmitURL = newLogin.formActionOrigin; loginItem.usernameField = newLogin.usernameField; loginItem.passwordField = newLogin.passwordField; loginItem.encryptedUsername = encUsername; loginItem.encryptedPassword = encPassword; loginItem.guid = newLogin.guid; loginItem.encType = encType; loginItem.timeCreated = newLogin.timeCreated; loginItem.timeLastUsed = newLogin.timeLastUsed; loginItem.timePasswordChanged = newLogin.timePasswordChanged; loginItem.timesUsed = newLogin.timesUsed; loginItem.timeLastBreachAlertDismissed = newLogin.timeLastBreachAlertDismissed; loginItem.encryptedUnknownFields = encUnknownFields; loginItem.syncCounter = newLogin.syncCounter; this._store.saveSoon(); break; } } lazy.LoginHelper.notifyStorageChanged("modifyLogin", [ oldStoredLogin, newLogin, ]); } // Replace the login with a tombstone. It has a guid and sync-related properties, // but does not contain the login or password information. #replaceLoginWithTombstone(login) { login.deleted = true; // Delete all fields except guid, timePasswordChanged, syncCounter // and everSynced; delete login.hostname; delete login.httpRealm; delete login.formSubmitURL; delete login.usernameField; delete login.passwordField; delete login.encryptedUsername; delete login.encryptedPassword; delete login.encType; delete login.timeCreated; delete login.timeLastUsed; delete login.timesUsed; delete login.encryptedUnknownFields; } /** * @deprecated Use recordPasswordUseAsync instead. * Will be removed in Bug 2022270. */ recordPasswordUse(login) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.recordPasswordUse() is deprecated. Use recordPasswordsUseAsync(). Will be removed in Bug 2022270." ); // Update the lastUsed timestamp and increment the use count. let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( Ci.nsIWritablePropertyBag ); propBag.setProperty("timeLastUsed", Date.now()); propBag.setProperty("timesUsedIncrement", 1); this.modifyLogin(login, propBag); } async recordPasswordUseAsync(login) { // Update the lastUsed timestamp and increment the use count. let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( Ci.nsIWritablePropertyBag ); propBag.setProperty("timeLastUsed", Date.now()); propBag.setProperty("timesUsedIncrement", 1); this.modifyLoginAsync(login, propBag); } async recordBreachAlertDismissal(loginGUID) { this._store.ensureDataReady(); const login = this._store.data.logins.find( l => l.guid === loginGUID && !l.deleted ); if (login) { login.timeLastBreachAlertDismissed = Date.now(); this._store.saveSoon(); } } async getBreachAlertDismissalsByLoginGUID() { this._store.ensureDataReady(); const result = {}; for (const login of this._store.data.logins) { if (login.timeLastBreachAlertDismissed) { result[login.guid] = { timeBreachAlertDismissed: login.timeLastBreachAlertDismissed, }; } } return result; } /** * Returns an array of nsILoginInfo. If decryption of a login * fails due to a corrupt entry, the login is not included in * the resulting array. * * @returns {Promise} */ async getAllLogins(includeDeleted) { this._store.ensureDataReady(); let [logins] = this._searchLogins({}, includeDeleted); if (!logins.length) { return []; } return this.#decryptLogins(logins); } /** * Public wrapper around _searchLogins to convert the nsIPropertyBag to a * JavaScript object and decrypt the results. * * @return {nsILoginInfo[]} which are decrypted. */ async searchLoginsAsync(matchData, includeDeleted) { lazy.logger.log( `Searching for matching logins for origin ${matchData.origin}.` ); this._store.ensureDataReady(); let realMatchData = {}; let options = {}; if ("guid" in matchData) { realMatchData.guid = matchData.guid; } else { for (const name in matchData) { switch (name) { case "acceptDifferentSubdomains": case "schemeUpgrades": case "acceptRelatedRealms": case "relatedRealms": options[name] = matchData[name]; break; default: realMatchData[name] = matchData[name]; break; } } } let [logins] = this._searchLogins(realMatchData, includeDeleted, options); return this._decryptLogins(logins); } /** * @deprecated Use searchLoginsAsync instead. * Will be removed in Bug 2022270. */ searchLogins(matchData, includeDeleted) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.searchLogin() is deprecated. Use searchLoginAsync(). Will be removed in Bug 2022270." ); this._store.ensureDataReady(); let realMatchData = {}; let options = {}; matchData.QueryInterface(Ci.nsIPropertyBag2); if (matchData.hasKey("guid")) { // Enforce GUID-based filtering when available, since the origin of the // login may not match the origin of the form in the case of scheme // upgrades. realMatchData = { guid: matchData.getProperty("guid") }; } else { // Convert nsIPropertyBag to normal JS object. for (let prop of matchData.enumerator) { switch (prop.name) { // Some property names aren't field names but are special options to // affect the search. case "acceptDifferentSubdomains": case "schemeUpgrades": case "acceptRelatedRealms": case "relatedRealms": { options[prop.name] = prop.value; break; } default: { realMatchData[prop.name] = prop.value; break; } } } } let [logins] = this._searchLogins(realMatchData, includeDeleted, options); // Decrypt entries found for the caller. logins = this._decryptLogins(logins); return logins; } /** * Private method to perform arbitrary searches on any field. Decryption is * left to the caller. * * formActionOrigin is handled specially for compatibility. If a null string * is passed and other match fields are present, it is treated as if it was * not present. * * Returns [logins, ids] for logins that match the arguments, where logins * is an array of encrypted nsLoginInfo and ids is an array of associated * ids in the database. */ _searchLogins( matchData, includeDeleted = false, aOptions = { schemeUpgrades: false, acceptDifferentSubdomains: false, acceptRelatedRealms: false, relatedRealms: [], }, candidateLogins = this._store.data.logins ) { let foundLogins = [], foundIds = []; for (let loginItem of candidateLogins) { if (loginItem.deleted && !includeDeleted) { continue; // skip deleted items } if (this.#matchLogin(loginItem, matchData, aOptions)) { // Create the new nsLoginInfo object, push to array let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( Ci.nsILoginInfo ); login.init( loginItem.hostname, loginItem.formSubmitURL, loginItem.httpRealm, loginItem.encryptedUsername, loginItem.encryptedPassword, loginItem.usernameField, loginItem.passwordField ); // set nsILoginMetaInfo values login.QueryInterface(Ci.nsILoginMetaInfo); login.guid = loginItem.guid; login.timeCreated = loginItem.timeCreated; login.timeLastUsed = loginItem.timeLastUsed; login.timePasswordChanged = loginItem.timePasswordChanged; login.timesUsed = loginItem.timesUsed; login.syncCounter = loginItem.syncCounter; login.everSynced = loginItem.everSynced; login.timeLastBreachAlertDismissed = loginItem.timeLastBreachAlertDismissed; // Any unknown fields along for the ride login.unknownFields = loginItem.encryptedUnknownFields; foundLogins.push(login); foundIds.push(loginItem.id); } } lazy.logger.log( `Returning ${foundLogins.length} logins for specified origin with options ${aOptions}` ); return [foundLogins, foundIds]; } /** * @deprecated Use removeAllLoginsAsync instead. * Will be removed in Bug 2022270. */ removeAllLogins() { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.removeAllLogins() is deprecated. Use removeAllLoginsAsync(). Will be removed in Bug 2022270." ); this.#removeLogins(false, true); } /** * Removes all logins from local storage, including FxA Sync key. * * NOTE: You probably want removeAllUserFacingLogins instead of this function. * */ async removeAllLoginsAsync() { this.#removeLogins(false, true); } /** * @deprecated Use removeAllUserFacingLoginsAsync instead. * Will be removed in Bug 2022270. */ removeAllUserFacingLogins(fullyRemove) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.removeAllUserFacingLogins() is deprecated. Use removeAllUserFacingLoginsAsync(). Will be removed in Bug 2022270." ); this.#removeLogins(fullyRemove, false); } /** * Removes all user facing logins from storage. e.g. all logins except the FxA Sync key * * If you need to remove the FxA key, use `removeAllLogins` instead * * @param fullyRemove remove the logins rather than mark them deleted. */ async removeAllUserFacingLoginsAsync(fullyRemove) { this.#removeLogins(fullyRemove, false); } /** * Removes all logins from storage. If removeFXALogin is true, then the FxA Sync * key is also removed. * * @param fullyRemove remove the logins rather than mark them deleted. * @param removeFXALogin also remove the FxA Sync key. */ #removeLogins(fullyRemove, removeFXALogin = false) { this._store.ensureDataReady(); lazy.logger.log("Removing all logins."); let removedLogins = []; let remainingLogins = []; for (let login of this._store.data.logins) { if ( !removeFXALogin && isFXAHost(login) && login.httpRealm == lazy.FXA_PWDMGR_REALM ) { remainingLogins.push(login); } else { // Create the nsLoginInfo object which to emit const loginInfo = Cc[ "@mozilla.org/login-manager/loginInfo;1" ].createInstance(Ci.nsILoginInfo); loginInfo.init( login.hostname, login.formSubmitURL, login.httpRealm, login.encryptedUsername, login.encryptedPassword, login.usernameField, login.passwordField ); // set nsILoginMetaInfo values loginInfo.QueryInterface(Ci.nsILoginMetaInfo); loginInfo.guid = login.guid; loginInfo.timeCreated = login.timeCreated; loginInfo.timeLastUsed = login.timeLastUsed; loginInfo.timePasswordChanged = login.timePasswordChanged; loginInfo.timesUsed = login.timesUsed; loginInfo.syncCounter = login.syncCounter; loginInfo.everSynced = login.everSynced; loginInfo.timeLastBreachAlertDismissed = login.timeLastBreachAlertDismissed; // Any unknown fields along for the ride loginInfo.unknownFields = login.encryptedUnknownFields; removedLogins.push(loginInfo); if (!fullyRemove && login?.everSynced) { // The login has been synced, so mark it as deleted. this.#incrementSyncCounter(login); this.#replaceLoginWithTombstone(login); remainingLogins.push(login); } } } this._store.data.logins = remainingLogins; this._store.data.potentiallyVulnerablePasswords = []; this.__decryptedPotentiallyVulnerablePasswords = null; this._store.saveSoon(); lazy.LoginHelper.notifyStorageChanged("removeAllLogins", removedLogins); } findLogins(origin, formActionOrigin, httpRealm) { this._store.ensureDataReady(); let loginData = { origin, formActionOrigin, httpRealm, }; let matchData = {}; for (let field of ["origin", "formActionOrigin", "httpRealm"]) { if (loginData[field] != "") { matchData[field] = loginData[field]; } } let [logins] = this._searchLogins(matchData); // Decrypt entries found for the caller. logins = this._decryptLogins(logins); lazy.logger.log(`Returning ${logins.length} logins.`); return logins; } /** * Checks if the given login item matches the specified matchData. * * @param {object} aLoginItem The login item to check. * @param {object} aMatchData The match data to compare against. keyed by * @param {object} [aOptions] Additional options for matching * * @returns {boolean} - Returns true if the login item matches the match data, */ #matchLogin( aLoginItem, aMatchData, aOptions = { schemeUpgrades: false, acceptDifferentSubdomains: false, acceptRelatedRealms: false, relatedRealms: [], } ) { for (let field in aMatchData) { let wantedValue = aMatchData[field]; // Override the storage field name for some fields due to backwards // compatibility with Sync/storage. let storageFieldName = field; switch (field) { case "formActionOrigin": { storageFieldName = "formSubmitURL"; break; } case "origin": { storageFieldName = "hostname"; break; } } switch (field) { case "formActionOrigin": if (wantedValue != null) { // Historical compatibility requires this special case if ( aLoginItem.formSubmitURL == "" || (wantedValue == "" && Object.keys(aMatchData).length != 1) ) { break; } if ( !lazy.LoginHelper.isOriginMatching( aLoginItem[storageFieldName], wantedValue, aOptions ) ) { return false; } break; } // fall through case "origin": if (wantedValue != null) { // needed for formActionOrigin fall through if ( !lazy.LoginHelper.isOriginMatching( aLoginItem[storageFieldName], wantedValue, aOptions ) ) { return false; } break; } // Normal cases. // fall through case "httpRealm": case "id": case "usernameField": case "passwordField": case "encryptedUsername": case "encryptedPassword": case "guid": case "encType": case "timeCreated": case "timeLastUsed": case "timePasswordChanged": case "timesUsed": case "syncCounter": case "everSynced": if (wantedValue == null && aLoginItem[storageFieldName]) { return false; } else if (aLoginItem[storageFieldName] != wantedValue) { return false; } break; // Fail if caller requests an unknown property. default: throw new Error("Unexpected field: " + field); } } return true; } /** * @deprecated Use countLoginsAsync instead. * Will be removed in Bug 2022270. */ countLogins(origin, formActionOrigin, httpRealm) { lazy.logger.warn( "DEPRECATED: LoginManagerStorage_json.countLogins() is deprecated. Use countLoginsAsync(). Will be removed in Bug 2022270." ); this._store.ensureDataReady(); let loginData = { origin, formActionOrigin, httpRealm, }; let matchData = {}; for (let field of ["origin", "formActionOrigin", "httpRealm"]) { if (loginData[field] != "") { matchData[field] = loginData[field]; } } const foundLogins = this._store.data.logins.filter( loginItem => !loginItem.deleted && this.#matchLogin(loginItem, matchData) ); lazy.logger.log(`Counted ${foundLogins.length} logins.`); return foundLogins.length; } async countLoginsAsync(origin, formActionOrigin, httpRealm) { this._store.ensureDataReady(); let loginData = { origin, formActionOrigin, httpRealm, }; let matchData = {}; for (let field of ["origin", "formActionOrigin", "httpRealm"]) { if (loginData[field] != "") { matchData[field] = loginData[field]; } } const foundLogins = this._store.data.logins.filter( loginItem => !loginItem.deleted && this.#matchLogin(loginItem, matchData) ); lazy.logger.log(`Counted ${foundLogins.length} logins.`); return foundLogins.length; } async addPotentiallyVulnerablePassword(login) { this._store.ensureDataReady(); // this breached password is already stored // note this builds the __decryptedPotentiallyVulnerablePasswords structure if (await this.isPotentiallyVulnerablePassword(login)) { return; } this.__decryptedPotentiallyVulnerablePasswords.push(login.password); this._store.data.potentiallyVulnerablePasswords.push({ encryptedPassword: this._crypto.encrypt(login.password), }); this._store.saveSoon(); lazy.LoginHelper.notifyStorageChanged( "addPotentiallyVulnerablePassword", login ); } async isPotentiallyVulnerablePassword(login) { return this.decryptedPotentiallyVulnerablePasswords.includes( login.password ); } async arePotentiallyVulnerablePasswords(logins) { return logins .filter(l => this.decryptedPotentiallyVulnerablePasswords.includes(l.password) ) .map(l => l.guid); } async clearAllPotentiallyVulnerablePasswords() { this._store.ensureDataReady(); if (!this._store.data.potentiallyVulnerablePasswords.length) { // No need to write to disk return; } this._store.data.potentiallyVulnerablePasswords = []; this._store.saveSoon(); this.__decryptedPotentiallyVulnerablePasswords = null; lazy.LoginHelper.notifyStorageChanged( "clearAllPotentiallyVulnerablePasswords" ); } get uiBusy() { return this._crypto.uiBusy; } get isLoggedIn() { return this._crypto.isLoggedIn; } /** * Returns an array with two items: [id, login]. If the login was not * found, both items will be null. The returned login contains the actual * stored login (useful for looking at the actual nsILoginMetaInfo values). */ _getIdForLogin(login) { this._store.ensureDataReady(); let matchData = {}; for (let field of ["origin", "formActionOrigin", "httpRealm"]) { if (login[field] != "") { matchData[field] = login[field]; } } let [logins, ids] = this._searchLogins(matchData); let id = null; let foundLogin = null; // The specified login isn't encrypted, so we need to ensure // the logins we're comparing with are decrypted. We decrypt one entry // at a time, lest _decryptLogins return fewer entries and screw up // indices between the two. for (let i = 0; i < logins.length; i++) { let [decryptedLogin] = this._decryptLogins([logins[i]]); if (!decryptedLogin || !decryptedLogin.equals(login)) { continue; } // We've found a match, set id and break foundLogin = decryptedLogin; id = ids[i]; break; } return [id, foundLogin]; } /** * Checks to see if the specified GUID already exists. */ _isGuidUnique(guid) { this._store.ensureDataReady(); return this._store.data.logins.every(l => l.guid != guid); } /* * Asynchronously encrypt multiple logins. * Returns a promise resolving to an array of arrays containing two entries: * the original login and a clone with encrypted properties. */ async #encryptLogins(logins) { if (logins.length === 0) { return logins; } const plaintexts = logins.reduce( (memo, { username, password, unknownFields }) => memo.concat([username, password, unknownFields]), [] ); const ciphertexts = await this._crypto.encryptMany(plaintexts); return logins.map((login, i) => { const [encryptedUsername, encryptedPassword, encryptedUnknownFields] = ciphertexts.slice(3 * i, 3 * i + 3); const encryptedLogin = login.clone(); encryptedLogin.username = encryptedUsername; encryptedLogin.password = encryptedPassword; encryptedLogin.unknownFields = encryptedUnknownFields; return [login, encryptedLogin]; }); } /* * Asynchronously decrypt multiple logins. * Returns a promise resolving to an array of clones with decrypted properties. */ async #decryptLogins(logins) { if (logins.length === 0) { return logins; } const ciphertexts = logins.reduce( (memo, { username, password, unknownFields }) => memo.concat([username, password, unknownFields]), [] ); const plaintexts = await this._crypto.decryptMany(ciphertexts); return logins .map((login, i) => { // Deleted logins don't have any info to decrypt. const decryptedLogin = login.clone(); if (this.#loginIsDeleted(login.guid)) { return decryptedLogin; } const [username, password, unknownFields] = plaintexts.slice( 3 * i, 3 * i + 3 ); // If the username or password is blank it means that decryption may have // failed during decryptMany but we can't differentiate an empty string // value from a failure so we attempt to decrypt again and check the // result. if (!username || !password) { try { this._crypto.decrypt(login.username); this._crypto.decrypt(login.password); } catch (e) { // If decryption failed (corrupt entry?), just return it as it is. // Rethrow other errors (like canceling entry of a primary pw) if (e.result == Cr.NS_ERROR_FAILURE) { lazy.logger.log( `Could not decrypt login: ${ login.QueryInterface(Ci.nsILoginMetaInfo).guid }.` ); return null; } throw e; } } decryptedLogin.username = username; decryptedLogin.password = password; decryptedLogin.unknownFields = unknownFields; return decryptedLogin; }) .filter(Boolean); } /** * Returns the encrypted username, password, and encrypton type for the specified * login. Can throw if the user cancels a primary password entry. */ _encryptLogin(login) { let encUsername = this._crypto.encrypt(login.username); let encPassword = this._crypto.encrypt(login.password); // Unknown fields should be encrypted since we can't know whether new fields // from other clients will contain sensitive data or not let encUnknownFields = null; if (login.unknownFields) { encUnknownFields = this._crypto.encrypt(login.unknownFields); } let encType = this._crypto.defaultEncType; return [encUsername, encPassword, encType, encUnknownFields]; } /** * Decrypts username and password fields in the provided array of * logins. * * The entries specified by the array will be decrypted, if possible. * An array of successfully decrypted logins will be returned. The return * value should be given to external callers (since still-encrypted * entries are useless), whereas internal callers generally don't want * to lose unencrypted entries (eg, because the user clicked Cancel * instead of entering their primary password) */ _decryptLogins(logins) { let result = []; for (let login of logins) { if (this.#loginIsDeleted(login.guid)) { result.push(login); continue; } try { login.username = this._crypto.decrypt(login.username); login.password = this._crypto.decrypt(login.password); // Verify unknownFields actually has a value if (login.unknownFields) { login.unknownFields = this._crypto.decrypt(login.unknownFields); } } catch (e) { // If decryption failed (corrupt entry?), just skip it. // Rethrow other errors (like canceling entry of a primary pw) if (e.result == Cr.NS_ERROR_FAILURE) { continue; } throw e; } result.push(login); } return result; } reencryptionInProgress = false; /** * For migration purposes, asynchronously reencrypt all logins in the * background. */ async reencryptAllLogins() { if (this.reencryptionInProgress) { return; } this.reencryptionInProgress = true; this._store.ensureDataReady(); const encryptedLogins = structuredClone( this._store.data.logins.filter(login => !this.#loginIsDeleted(login.guid)) ); let encrypted = encryptedLogins.flatMap( ({ encryptedUsername, encryptedPassword, encryptedUnknownFields }) => [ encryptedUsername, encryptedPassword, encryptedUnknownFields, ] ); // Calling decryptMany / encryptMany with an empty array would throw an // error, so just don't do it if there are no logins. if (encryptedLogins.length) { const decrypted = await this._crypto .decryptMany(encrypted) .catch(error => { this.reencryptionInProgress = false; throw error; }); encrypted = await this._crypto.encryptMany(decrypted).catch(error => { this.reencryptionInProgress = false; throw error; }); } for (let oldIndex = 0; oldIndex < encryptedLogins.length; oldIndex++) { const oldLogin = encryptedLogins[oldIndex]; const newLogin = this._store.data.logins.find( login => login.id === oldLogin.id ); if ( !newLogin || newLogin.encryptedUsername != oldLogin.encryptedUsername || newLogin.encryptedPassword != oldLogin.encryptedPassword || newLogin.encryptedUnknownFields != oldLogin.encryptedUnknownFields ) { // This login has been changed or got deleted while we were // asynchronously reencrypting the logins. As we shoudn't overwrite it // and potentially loose the update, we will just skip it. lazy.logger.log( `Login ${ oldLogin.guid } changed during migration and doesn't need to be updated.` ); continue; } newLogin.encryptedUsername = encrypted[oldIndex * 3]; newLogin.encryptedPassword = encrypted[oldIndex * 3 + 1]; newLogin.encryptedUnknownFields = encrypted[oldIndex * 3 + 2]; } // Save the logins changed by us to disk if there are any if (encryptedLogins.length) { // This could throw if we are in shutdown phase and the store is already // finalized. Thus, it is important we call this before clearing // signon.reencryptionNeeded below, to make sure we will retry the // reencryption on the next restart in case this fails. this._store.saveSoon(); } Services.prefs.clearUserPref("signon.reencryptionNeeded"); this.reencryptionInProgress = false; if (this.addedLoginObserver) { Services.obs.removeObserver(this, "passwordmgr-crypto-login"); } } /** * Pref observer for signon.reencryptionNeeded */ #observeReencryptionNeeded() { if (Services.prefs.getBoolPref("signon.reencryptionNeeded", false)) { // Only reencrypt if user is logged in. Else, wait until the login has // happened. if (this.isLoggedIn) { this.reencryptAllLogins(); } else if (!this.addedLoginObserver) { Services.obs.addObserver(this, "passwordmgr-crypto-login"); this.addedLoginObserver = true; } } } observe(_, topic) { // If we need to reencrypt, and weren't able to do so on startup because a // primary password is set, we can retry doing so now. if ( topic === "passwordmgr-crypto-login" && Services.prefs.getBoolPref("signon.reencryptionNeeded", false) ) { this.reencryptAllLogins(); } } }