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;
|
|
}
|
|
|
|
// Synchronuously 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();
|
|
|
|
Glean.pwmgr.numSavedPasswords.set(this.countLogins("", "", ""));
|
|
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();
|
|
}
|
|
}
|
|
|
|
Glean.pwmgr.numSavedPasswords.set(this.countLogins("", "", ""));
|
|
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);
|
|
});
|