forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			1058 lines
		
	
	
	
		
			32 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1058 lines
		
	
	
	
		
			32 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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,
 | |
|         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,
 | |
|         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.
 | |
|    *
 | |
|    * 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
 | |
|   ) {
 | |
|     if (
 | |
|       "formActionOrigin" in matchData &&
 | |
|       matchData.formActionOrigin === "" &&
 | |
|       // Carve an exception out for a unit test in test_legacy_empty_formSubmitURL.js
 | |
|       Object.keys(matchData).length != 1
 | |
|     ) {
 | |
|       throw new Error(
 | |
|         "Searching with an empty `formActionOrigin` doesn't do a wildcard search"
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     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 == "") {
 | |
|                 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);
 | |
| });
 | 
