summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/storage-json.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/passwordmgr/storage-json.sys.mjs')
-rw-r--r--toolkit/components/passwordmgr/storage-json.sys.mjs1056
1 files changed, 1056 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/storage-json.sys.mjs b/toolkit/components/passwordmgr/storage-json.sys.mjs
new file mode 100644
index 0000000000..36cc1c3c88
--- /dev/null
+++ b/toolkit/components/passwordmgr/storage-json.sys.mjs
@@ -0,0 +1,1056 @@
+/* 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",
+});
+
+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;
+ }
+
+ 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");
+ }
+ this._store = new lazy.LoginStore(jsonPath, backupPath);
+
+ return (async () => {
+ // Load the data asynchronously.
+ this.log(`Opening database at ${this._store.path}.`);
+ await this._store.load();
+ })().catch(console.error);
+ } catch (e) {
+ this.log(`Initialization failed ${e.name}.`);
+ throw new Error("Initialization failed");
+ }
+ }
+
+ /**
+ * Internal method used by regression tests only. It is called before
+ * replacing this storage module with a new instance.
+ */
+ terminate() {
+ 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) {
+ this.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;
+ }
+
+ // Synrhronuously stores encrypted login, returns login clone with upserted
+ // uuid and updated timestamps
+ #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,
+ syncCounter: loginClone.syncCounter,
+ everSynced: loginClone.everSynced,
+ encryptedUnknownFields: loginClone.unknownFields,
+ });
+ this._store.saveSoon();
+
+ 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 = 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;
+ }
+
+ removeLogin(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 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();
+ }
+ }
+
+ lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin);
+ }
+
+ modifyLogin(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)) {
+ 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.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;
+ }
+
+ recordPasswordUse(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.modifyLogin(login, propBag);
+ }
+
+ async recordBreachAlertDismissal(loginGUID) {
+ this._store.ensureDataReady();
+ const dismissedBreachAlertsByLoginGUID =
+ this._store._data.dismissedBreachAlertsByLoginGUID;
+
+ dismissedBreachAlertsByLoginGUID[loginGUID] = {
+ timeBreachAlertDismissed: new Date().getTime(),
+ };
+
+ return this._store.saveSoon();
+ }
+
+ getBreachAlertDismissalsByLoginGUID() {
+ this._store.ensureDataReady();
+ return this._store._data.dismissedBreachAlertsByLoginGUID;
+ }
+
+ /**
+ * 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.
+ *
+ * @resolve {nsILoginInfo[]}
+ */
+ async getAllLogins(includeDeleted) {
+ this._store.ensureDataReady();
+
+ let [logins] = this._searchLogins({}, includeDeleted);
+ if (!logins.length) {
+ return [];
+ }
+
+ return this.#decryptLogins(logins);
+ }
+
+ async searchLoginsAsync(matchData, includeDeleted) {
+ this.log(`Searching for matching logins for origin ${matchData.origin}.`);
+ let result = this.searchLogins(
+ lazy.LoginHelper.newPropertyBag(matchData),
+ includeDeleted
+ );
+ // Emulate being async:
+ return Promise.resolve(result);
+ }
+
+ /**
+ * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
+ * JavaScript object and decrypt the results.
+ *
+ * @return {nsILoginInfo[]} which are decrypted.
+ */
+ searchLogins(matchData, includeDeleted) {
+ 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
+ ) {
+ function match(aLoginItem) {
+ for (let field in matchData) {
+ let wantedValue = matchData[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(matchData).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;
+ }
+
+ let foundLogins = [],
+ foundIds = [];
+ for (let loginItem of candidateLogins) {
+ if (loginItem.deleted && !includeDeleted) {
+ continue; // skip deleted items
+ }
+
+ if (match(loginItem)) {
+ // 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;
+
+ // Any unknown fields along for the ride
+ login.unknownFields = loginItem.encryptedUnknownFields;
+ foundLogins.push(login);
+ foundIds.push(loginItem.id);
+ }
+ }
+
+ this.log(
+ `Returning ${foundLogins.length} logins for specified origin with options ${aOptions}`
+ );
+ return [foundLogins, foundIds];
+ }
+
+ /**
+ * Removes all logins from local storage, including FxA Sync key.
+ *
+ * NOTE: You probably want removeAllUserFacingLogins instead of this function.
+ *
+ */
+ removeAllLogins() {
+ this.#removeLogins(false, true);
+ }
+
+ /**
+ * 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.
+ */
+ removeAllUserFacingLogins(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();
+ this.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 {
+ removedLogins.push(login);
+ 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.data.dismissedBreachAlertsByLoginGUID = {};
+ 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);
+
+ this.log(`Returning ${logins.length} logins.`);
+ return logins;
+ }
+
+ countLogins(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);
+
+ this.log(`Counted ${logins.length} logins.`);
+ return logins.length;
+ }
+
+ addPotentiallyVulnerablePassword(login) {
+ this._store.ensureDataReady();
+ // this breached password is already stored
+ if (this.isPotentiallyVulnerablePassword(login)) {
+ return;
+ }
+ this.__decryptedPotentiallyVulnerablePasswords.push(login.password);
+
+ this._store.data.potentiallyVulnerablePasswords.push({
+ encryptedPassword: this._crypto.encrypt(login.password),
+ });
+ this._store.saveSoon();
+ }
+
+ isPotentiallyVulnerablePassword(login) {
+ return this._decryptedPotentiallyVulnerablePasswords.includes(
+ login.password
+ );
+ }
+
+ 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;
+ }
+
+ 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) {
+ this.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;
+ }
+}
+
+ChromeUtils.defineLazyGetter(LoginManagerStorage_json.prototype, "log", () => {
+ let logger = lazy.LoginHelper.createLogger("Login storage");
+ return logger.log.bind(logger);
+});