fune/toolkit/components/passwordmgr/storage-json.js

856 lines
26 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/. */
/**
* nsILoginManagerStorage implementation for the JSON back-end.
*/
"use strict";
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js",
FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js",
LoginHelper: "resource://gre/modules/LoginHelper.jsm",
LoginStore: "resource://gre/modules/LoginStore.jsm",
});
class LoginManagerStorage_json {
constructor() {
this.__crypto = null; // nsILoginManagerCrypto service
this.__decryptedPotentiallyVulnerablePasswords = null;
}
get classID() {
return Components.ID("{c00c432d-a0c9-46d7-bef6-9c45b4d07341}");
}
get QueryInterface() {
return ChromeUtils.generateQI(["nsILoginManagerStorage"]);
}
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(Cu.reportError);
} 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();
}
addLogin(
login,
preEncrypted = false,
plaintextUsername = null,
plaintextPassword = null
) {
if (
preEncrypted &&
(typeof plaintextUsername != "string" ||
typeof plaintextPassword != "string")
) {
throw new Error(
"plaintextUsername and plaintextPassword are required when preEncrypted is true"
);
}
this._store.ensureDataReady();
// Throws if there are bogus values.
lazy.LoginHelper.checkLoginValues(login);
let [encUsername, encPassword, encType] = preEncrypted
? [login.username, login.password, this._crypto.defaultEncType]
: this._encryptLogin(login);
// Clone the login, so we don't modify the caller's object.
let loginClone = login.clone();
loginClone.username = preEncrypted ? plaintextUsername : login.username;
loginClone.password = preEncrypted ? plaintextPassword : login.password;
// 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;
}
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: encUsername,
encryptedPassword: encPassword,
guid: loginClone.guid,
encType,
timeCreated: loginClone.timeCreated,
timeLastUsed: loginClone.timeLastUsed,
timePasswordChanged: loginClone.timePasswordChanged,
timesUsed: loginClone.timesUsed,
});
this._store.saveSoon();
// Send a notification that a login was added.
lazy.LoginHelper.notifyStorageChanged("addLogin", loginClone);
return loginClone;
}
removeLogin(login) {
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) {
this._store.data.logins.splice(foundIndex, 1);
this._store.saveSoon();
}
lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin);
}
modifyLogin(oldLogin, newLoginData) {
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 logins = this.findLogins(
newLogin.origin,
newLogin.formActionOrigin,
newLogin.httpRealm
);
let matchingLogin = logins.find(login => newLogin.matches(login, true));
if (matchingLogin) {
throw lazy.LoginHelper.createLoginAlreadyExistsError(
matchingLogin.guid
);
}
}
// Get the encrypted value of the username and password.
let [encUsername, encPassword, encType] = this._encryptLogin(newLogin);
for (let loginItem of this._store.data.logins) {
if (loginItem.id == idToModify) {
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;
this._store.saveSoon();
break;
}
}
lazy.LoginHelper.notifyStorageChanged("modifyLogin", [
oldStoredLogin,
newLogin,
]);
}
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;
}
/**
* @return {nsILoginInfo[]}
*/
getAllLogins() {
this._store.ensureDataReady();
let [logins] = this._searchLogins({});
// decrypt entries for caller.
logins = this._decryptLogins(logins);
this.log(`Returning ${logins.length} logins.`);
return logins;
}
/**
* 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 getAllLoginsAsync() {
this._store.ensureDataReady();
let [logins] = this._searchLogins({});
if (!logins.length) {
return [];
}
let ciphertexts = logins
.map(l => l.username)
.concat(logins.map(l => l.password));
let plaintexts = await this._crypto.decryptMany(ciphertexts);
let usernames = plaintexts.slice(0, logins.length);
let passwords = plaintexts.slice(logins.length);
let result = [];
for (let i = 0; i < logins.length; i++) {
if (!usernames[i] || !passwords[i]) {
// 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.
let login = logins[i];
try {
this._crypto.decrypt(login.username);
this._crypto.decrypt(login.password);
} 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) {
this.log(
`Could not decrypt login: ${
login.QueryInterface(Ci.nsILoginMetaInfo).guid
}.`
);
continue;
}
throw e;
}
}
logins[i].username = usernames[i];
logins[i].password = passwords[i];
result.push(logins[i]);
}
return result;
}
async searchLoginsAsync(matchData) {
this.log(`Searching for matching logins for origin ${matchData.origin}.`);
let result = this.searchLogins(lazy.LoginHelper.newPropertyBag(matchData));
// 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) {
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, 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,
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":
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 (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;
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._store.ensureDataReady();
this._store.data.logins = [];
this._store.data.potentiallyVulnerablePasswords = [];
this.__decryptedPotentiallyVulnerablePasswords = null;
this._store.data.dismissedBreachAlertsByLoginGUID = {};
this._store.saveSoon();
lazy.LoginHelper.notifyStorageChanged("removeAllLogins", []);
}
/**
* 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
*/
removeAllUserFacingLogins() {
this._store.ensureDataReady();
this.log("Removing all logins.");
let [allLogins] = this._searchLogins({});
let fxaKey = this._store.data.logins.find(
login =>
login.hostname == lazy.FXA_PWDMGR_HOST &&
login.httpRealm == lazy.FXA_PWDMGR_REALM
);
if (fxaKey) {
this._store.data.logins = [fxaKey];
allLogins = allLogins.filter(item => item != fxaKey);
} else {
this._store.data.logins = [];
}
this._store.data.potentiallyVulnerablePasswords = [];
this.__decryptedPotentiallyVulnerablePasswords = null;
this._store.data.dismissedBreachAlertsByLoginGUID = {};
this._store.saveSoon();
lazy.LoginHelper.notifyStorageChanged("removeAllLogins", allLogins);
}
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);
}
/**
* 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);
let encType = this._crypto.defaultEncType;
return [encUsername, encPassword, encType];
}
/**
* 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) {
try {
login.username = this._crypto.decrypt(login.username);
login.password = this._crypto.decrypt(login.password);
} 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;
}
}
XPCOMUtils.defineLazyGetter(LoginManagerStorage_json.prototype, "log", () => {
let logger = lazy.LoginHelper.createLogger("Login storage");
return logger.log.bind(logger);
});
const EXPORTED_SYMBOLS = ["LoginManagerStorage_json"];