Bug 853549 - Use a JSON storage back-end in the Login Manager, except on Android. r=dolske

This commit is contained in:
Paolo Amadini 2014-01-07 17:29:41 +01:00
parent f227a9ce99
commit e4ede54cb5
17 changed files with 1931 additions and 215 deletions

View File

@ -388,7 +388,7 @@
#endif
@BINPATH@/components/SiteSpecificUserAgent.js
@BINPATH@/components/SiteSpecificUserAgent.manifest
@BINPATH@/components/storage-mozStorage.js
@BINPATH@/components/storage-json.js
@BINPATH@/components/crypto-SDR.js
@BINPATH@/components/jsconsole-clhandler.manifest
@BINPATH@/components/jsconsole-clhandler.js

View File

@ -1128,6 +1128,18 @@ var gBrowserInit = {
}
}, 10000);
// Load the Login Manager data from disk off the main thread, some time
// after startup. If the data is required before the timeout, for example
// because a restored page contains a password field, it will be loaded on
// the main thread, and this initialization request will be ignored.
setTimeout(function() {
try {
Services.logins;
} catch (ex) {
Cu.reportError(ex);
}
}, 3000);
// The object handling the downloads indicator is also initialized here in the
// delayed startup function, but the actual indicator element is not loaded
// unless there are downloads to be displayed.

View File

@ -390,7 +390,7 @@
@BINPATH@/components/nsLoginInfo.js
@BINPATH@/components/nsLoginManager.js
@BINPATH@/components/nsLoginManagerPrompter.js
@BINPATH@/components/storage-mozStorage.js
@BINPATH@/components/storage-json.js
@BINPATH@/components/crypto-SDR.js
@BINPATH@/components/jsconsole-clhandler.manifest
@BINPATH@/components/jsconsole-clhandler.js

View File

@ -113,11 +113,6 @@ function PasswordStore(name, engine) {
Store.call(this, name, engine);
this._nsLoginInfo = new Components.Constructor(
"@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
XPCOMUtils.defineLazyGetter(this, "DBConnection", function() {
return Services.logins.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.mozIStorageConnection);
});
}
PasswordStore.prototype = {
__proto__: Store.prototype,
@ -162,21 +157,6 @@ PasswordStore.prototype = {
return null;
},
applyIncomingBatch: function applyIncomingBatch(records) {
if (!this.DBConnection) {
return Store.prototype.applyIncomingBatch.call(this, records);
}
return Utils.runInTransaction(this.DBConnection, function() {
return Store.prototype.applyIncomingBatch.call(this, records);
}, this);
},
applyIncoming: function applyIncoming(record) {
Store.prototype.applyIncoming.call(this, record);
this._sleep(0); // Yield back to main thread after synchronous operation.
},
getAllIDs: function PasswordStore__getAllIDs() {
let items = {};
let logins = Services.logins.getAllLogins({});

View File

@ -150,22 +150,6 @@ this.Utils = {
};
},
runInTransaction: function(db, callback, thisObj) {
let hasTransaction = false;
try {
db.beginTransaction();
hasTransaction = true;
} catch(e) { /* om nom nom exceptions */ }
try {
return callback.call(thisObj);
} finally {
if (hasTransaction) {
db.commitTransaction();
}
}
},
/**
* GUIDs are 9 random bytes encoded with base64url (RFC 4648).
* That makes them 12 characters long with 72 bits of entropy.

View File

@ -0,0 +1,233 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80 filetype=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/. */
/**
* Contains functions shared by different Login Manager components.
*
* This JavaScript module exists in order to share code between the different
* XPCOM components that constitute the Login Manager, including implementations
* of nsILoginManager and nsILoginManagerStorage.
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"LoginHelper",
];
////////////////////////////////////////////////////////////////////////////////
//// Globals
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
////////////////////////////////////////////////////////////////////////////////
//// LoginHelper
/**
* Contains functions shared by different Login Manager components.
*/
this.LoginHelper = {
/**
* Due to the way the signons2.txt file is formatted, we need to make
* sure certain field values or characters do not cause the file to
* be parsed incorrectly. Reject hostnames that we can't store correctly.
*
* @throws String with English message in case validation failed.
*/
checkHostnameValue: function (aHostname)
{
// Nulls are invalid, as they don't round-trip well. Newlines are also
// invalid for any field stored as plaintext, and a hostname made of a
// single dot cannot be stored in the legacy format.
if (aHostname == "." ||
aHostname.indexOf("\r") != -1 ||
aHostname.indexOf("\n") != -1 ||
aHostname.indexOf("\0") != -1) {
throw "Invalid hostname";
}
},
/**
* Due to the way the signons2.txt file is formatted, we need to make
* sure certain field values or characters do not cause the file to
* be parsed incorrectly. Reject logins that we can't store correctly.
*
* @throws String with English message in case validation failed.
*/
checkLoginValues: function (aLogin)
{
function badCharacterPresent(l, c)
{
return ((l.formSubmitURL && l.formSubmitURL.indexOf(c) != -1) ||
(l.httpRealm && l.httpRealm.indexOf(c) != -1) ||
l.hostname.indexOf(c) != -1 ||
l.usernameField.indexOf(c) != -1 ||
l.passwordField.indexOf(c) != -1);
}
// Nulls are invalid, as they don't round-trip well.
// Mostly not a formatting problem, although ".\0" can be quirky.
if (badCharacterPresent(aLogin, "\0")) {
throw "login values can't contain nulls";
}
// In theory these nulls should just be rolled up into the encrypted
// values, but nsISecretDecoderRing doesn't use nsStrings, so the
// nulls cause truncation. Check for them here just to avoid
// unexpected round-trip surprises.
if (aLogin.username.indexOf("\0") != -1 ||
aLogin.password.indexOf("\0") != -1) {
throw "login values can't contain nulls";
}
// Newlines are invalid for any field stored as plaintext.
if (badCharacterPresent(aLogin, "\r") ||
badCharacterPresent(aLogin, "\n")) {
throw "login values can't contain newlines";
}
// A line with just a "." can have special meaning.
if (aLogin.usernameField == "." ||
aLogin.formSubmitURL == ".") {
throw "login values can't be periods";
}
// A hostname with "\ \(" won't roundtrip.
// eg host="foo (", realm="bar" --> "foo ( (bar)"
// vs host="foo", realm=" (bar" --> "foo ( (bar)"
if (aLogin.hostname.indexOf(" (") != -1) {
throw "bad parens in hostname";
}
},
/**
* Creates a new login object that results by modifying the given object with
* the provided data.
*
* @param aOldStoredLogin
* Existing nsILoginInfo object to modify.
* @param aNewLoginData
* The new login values, either as nsILoginInfo or nsIProperyBag.
*
* @return The newly created nsILoginInfo object.
*
* @throws String with English message in case validation failed.
*/
buildModifiedLogin: function (aOldStoredLogin, aNewLoginData)
{
function bagHasProperty(aPropName)
{
try {
aNewLoginData.getProperty(aPropName);
return true;
} catch (ex) { }
return false;
}
aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo);
let newLogin;
if (aNewLoginData instanceof Ci.nsILoginInfo) {
// Clone the existing login to get its nsILoginMetaInfo, then init it
// with the replacement nsILoginInfo data from the new login.
newLogin = aOldStoredLogin.clone();
newLogin.init(aNewLoginData.hostname,
aNewLoginData.formSubmitURL, aNewLoginData.httpRealm,
aNewLoginData.username, aNewLoginData.password,
aNewLoginData.usernameField, aNewLoginData.passwordField);
newLogin.QueryInterface(Ci.nsILoginMetaInfo);
// Automatically update metainfo when password is changed.
if (newLogin.password != aOldStoredLogin.password) {
newLogin.timePasswordChanged = Date.now();
}
} else if (aNewLoginData instanceof Ci.nsIPropertyBag) {
// Clone the existing login, along with all its properties.
newLogin = aOldStoredLogin.clone();
newLogin.QueryInterface(Ci.nsILoginMetaInfo);
// Automatically update metainfo when password is changed.
// (Done before the main property updates, lest the caller be
// explicitly updating both .password and .timePasswordChanged)
if (bagHasProperty("password")) {
let newPassword = aNewLoginData.getProperty("password");
if (newPassword != aOldStoredLogin.password) {
newLogin.timePasswordChanged = Date.now();
}
}
let propEnum = aNewLoginData.enumerator;
while (propEnum.hasMoreElements()) {
let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
switch (prop.name) {
// nsILoginInfo
case "hostname":
case "httpRealm":
case "formSubmitURL":
case "username":
case "password":
case "usernameField":
case "passwordField":
// nsILoginMetaInfo
case "guid":
case "timeCreated":
case "timeLastUsed":
case "timePasswordChanged":
case "timesUsed":
newLogin[prop.name] = prop.value;
break;
// Fake property, allows easy incrementing.
case "timesUsedIncrement":
newLogin.timesUsed += prop.value;
break;
// Fail if caller requests setting an unknown property.
default:
throw "Unexpected propertybag item: " + prop.name;
}
}
} else {
throw "newLoginData needs an expected interface!";
}
// Sanity check the login
if (newLogin.hostname == null || newLogin.hostname.length == 0) {
throw "Can't add a login with a null or empty hostname.";
}
// For logins w/o a username, set to "", not null.
if (newLogin.username == null) {
throw "Can't add a login with a null username.";
}
if (newLogin.password == null || newLogin.password.length == 0) {
throw "Can't add a login with a null or empty password.";
}
if (newLogin.formSubmitURL || newLogin.formSubmitURL == "") {
// We have a form submit URL. Can't have a HTTP realm.
if (newLogin.httpRealm != null) {
throw "Can't add a login with both a httpRealm and formSubmitURL.";
}
} else if (newLogin.httpRealm) {
// We have a HTTP realm. Can't have a form submit URL.
if (newLogin.formSubmitURL != null) {
throw "Can't add a login with both a httpRealm and formSubmitURL.";
}
} else {
// Need one or the other!
throw "Can't add a login without a httpRealm or formSubmitURL.";
}
// Throws if there are bogus values.
this.checkLoginValues(newLogin);
return newLogin;
},
};

View File

@ -0,0 +1,180 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80 filetype=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/. */
/**
* Provides an object that has a method to import login-related data from the
* previous SQLite storage format.
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"LoginImport",
];
////////////////////////////////////////////////////////////////////////////////
//// Globals
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
"resource://gre/modules/Sqlite.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
////////////////////////////////////////////////////////////////////////////////
//// LoginImport
/**
* Provides an object that has a method to import login-related data from the
* previous SQLite storage format.
*
* @param aStore
* LoginStore object where imported data will be added.
* @param aPath
* String containing the file path of the SQLite login database.
*/
this.LoginImport = function (aStore, aPath)
{
this.store = aStore;
this.path = aPath;
}
this.LoginImport.prototype = {
/**
* LoginStore object where imported data will be added.
*/
store: null,
/**
* String containing the file path of the SQLite login database.
*/
path: null,
/**
* Imports login-related data from the previous SQLite storage format.
*/
import: Task.async(function* () {
// We currently migrate data directly from the database to the JSON store at
// first run, then we set a preference to prevent repeating the import.
// Thus, merging with existing data is not a use case we support. This
// restriction might be removed to support re-importing passwords set by an
// old version by flipping the import preference and restarting.
if (this.store.data.logins.length > 0 ||
this.store.data.disabledHosts.length > 0) {
throw new Error("Unable to import saved passwords because some data " +
"has already been imported or saved.");
}
// When a timestamp is not specified, we will use the same reference time.
let referenceTimeMs = Date.now();
let connection = yield Sqlite.openConnection({ path: this.path });
try {
let schemaVersion = yield connection.getSchemaVersion();
// We support importing database schema versions from 3 onwards.
// Version 3 was implemented in bug 316084 (Firefox 3.6, March 2009).
// Version 4 was implemented in bug 465636 (Firefox 4, March 2010).
// Version 5 was implemented in bug 718817 (Firefox 13, February 2012).
if (schemaVersion < 3) {
throw new Error("Unable to import saved passwords because " +
"the existing profile is too old.");
}
let rows = yield connection.execute("SELECT * FROM moz_logins");
for (let row of rows) {
try {
let hostname = row.getResultByName("hostname");
let httpRealm = row.getResultByName("httpRealm");
let formSubmitURL = row.getResultByName("formSubmitURL");
let usernameField = row.getResultByName("usernameField");
let passwordField = row.getResultByName("passwordField");
let encryptedUsername = row.getResultByName("encryptedUsername");
let encryptedPassword = row.getResultByName("encryptedPassword");
// The "guid" field was introduced in schema version 2, and the
// "enctype" field was introduced in schema version 3. We don't
// support upgrading from older versions of the database.
let guid = row.getResultByName("guid");
let encType = row.getResultByName("encType");
// The time and count fields were introduced in schema version 4.
let timeCreated = null;
let timeLastUsed = null;
let timePasswordChanged = null;
let timesUsed = null;
try {
timeCreated = row.getResultByName("timeCreated");
timeLastUsed = row.getResultByName("timeLastUsed");
timePasswordChanged = row.getResultByName("timePasswordChanged");
timesUsed = row.getResultByName("timesUsed");
} catch (ex) { }
// These columns may be null either because they were not present in
// the database or because the record was created on a new schema
// version by an old application version.
if (!timeCreated) {
timeCreated = referenceTimeMs;
}
if (!timeLastUsed) {
timeLastUsed = referenceTimeMs;
}
if (!timePasswordChanged) {
timePasswordChanged = referenceTimeMs;
}
if (!timesUsed) {
timesUsed = 1;
}
this.store.data.logins.push({
id: this.store.data.nextId++,
hostname: hostname,
httpRealm: httpRealm,
formSubmitURL: formSubmitURL,
usernameField: usernameField,
passwordField: passwordField,
encryptedUsername: encryptedUsername,
encryptedPassword: encryptedPassword,
guid: guid,
encType: encType,
timeCreated: timeCreated,
timeLastUsed: timeLastUsed,
timePasswordChanged: timePasswordChanged,
timesUsed: timesUsed,
});
} catch (ex) {
Cu.reportError("Error importing login: " + ex);
}
}
rows = yield connection.execute("SELECT * FROM moz_disabledHosts");
for (let row of rows) {
try {
let id = row.getResultByName("id");
let hostname = row.getResultByName("hostname");
this.store.data.disabledHosts.push({
id: this.store.data.nextId++,
hostname: hostname,
});
} catch (ex) {
Cu.reportError("Error importing disabled host: " + ex);
}
}
} finally {
yield connection.close();
}
}),
};

View File

@ -0,0 +1,301 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80 filetype=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/. */
/**
* Handles serialization of login-related data and persistence into a file.
*
* This modules handles the raw data stored in JavaScript serializable objects,
* and contains no special validation or query logic, that is handled entirely
* by "storage.js" instead.
*
* The data can be manipulated only after it has been loaded from disk. The
* load process can happen asynchronously, through the "load" method, or
* synchronously, through "ensureDataReady". After any modification, the
* "saveSoon" method must be called to flush the data to disk asynchronously.
*
* The raw data should be manipulated synchronously, without waiting for the
* event loop or for promise resolution, so that the saved file is always
* consistent. This synchronous approach also simplifies the query and update
* logic. For example, it is possible to find an object and modify it
* immediately without caring whether other code modifies it in the meantime.
*
* An asynchronous shutdown observer makes sure that data is always saved before
* the browser is closed. The data cannot be modified during shutdown.
*
* The file is stored in JSON format, without indentation, using UTF-8 encoding.
* With indentation applied, the file would look like this:
*
* {
* "logins": [
* {
* "id": 2,
* "hostname": "http://www.example.com",
* "httpRealm": null,
* "formSubmitURL": "http://www.example.com/submit-url",
* "usernameField": "username_field",
* "passwordField": "password_field",
* "encryptedUsername": "...",
* "encryptedPassword": "...",
* "guid": "...",
* "encType": 1,
* "timeCreated": 1262304000000,
* "timeLastUsed": 1262304000000,
* "timePasswordChanged": 1262476800000,
* "timesUsed": 1
* },
* {
* "id": 4,
* (...)
* }
* ],
* "disabledHosts": [
* "http://www.example.org",
* "http://www.example.net"
* ],
* "nextId": 10,
* "version": 1
* }
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"LoginStore",
];
////////////////////////////////////////////////////////////////////////////////
//// Globals
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
"resource://gre/modules/AsyncShutdown.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask",
"resource://gre/modules/DeferredTask.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm")
XPCOMUtils.defineLazyGetter(this, "gTextDecoder", function () {
return new TextDecoder();
});
XPCOMUtils.defineLazyGetter(this, "gTextEncoder", function () {
return new TextEncoder();
});
const FileInputStream =
Components.Constructor("@mozilla.org/network/file-input-stream;1",
"nsIFileInputStream", "init");
/**
* Delay between a change to the login data and the related save operation.
*/
const kSaveDelayMs = 1500;
/**
* Current data version assigned by the code that last touched the data.
*
* This number should be updated only when it is important to understand whether
* an old version of the code has touched the data, for example to execute an
* update logic. In most cases, this number should not be changed, in
* particular when no special one-time update logic is needed.
*
* For example, this number should NOT be changed when a new optional field is
* added to a login entry.
*/
const kDataVersion = 1;
////////////////////////////////////////////////////////////////////////////////
//// LoginStore
/**
* Handles serialization of login-related data and persistence into a file.
*
* @param aPath
* String containing the file path where data should be saved.
*/
function LoginStore(aPath)
{
this.path = aPath;
this._saver = new DeferredTask(() => this.save(), kSaveDelayMs);
AsyncShutdown.profileBeforeChange.addBlocker("Login store: writing data",
() => this._saver.finalize());
}
LoginStore.prototype = {
/**
* String containing the file path where data should be saved.
*/
path: "",
/**
* Serializable object containing the login-related data. This is populated
* directly with the data loaded from the file, and is saved without
* modifications.
*
* This contains one property for each list.
*/
data: null,
/**
* True when data has been loaded.
*/
dataReady: false,
/**
* Loads persistent data from the file to memory.
*
* @return {Promise}
* @resolves When the operation finished successfully.
* @rejects JavaScript exception.
*/
load: function ()
{
return Task.spawn(function () {
try {
let bytes = yield OS.File.read(this.path);
// If synchronous loading happened in the meantime, exit now.
if (this.dataReady) {
return;
}
this.data = JSON.parse(gTextDecoder.decode(bytes));
} catch (ex) {
// If an exception occurred because the file did not exist, we should
// just start with new data. Other errors may indicate that the file is
// corrupt, thus we move it to a backup location before allowing it to
// be overwritten by an empty file.
if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) {
Cu.reportError(ex);
// Move the original file to a backup location, ignoring errors.
try {
let openInfo = yield OS.File.openUnique(this.path + ".corrupt",
{ humanReadable: true });
yield openInfo.file.close();
yield OS.File.move(this.path, openInfo.path);
} catch (e2) {
Cu.reportError(e2);
}
}
// In any case, initialize a new object to host the data.
this.data = {
nextId: 1,
};
}
this._processLoadedData();
}.bind(this));
},
/**
* Loads persistent data from the file to memory, synchronously.
*/
ensureDataReady: function ()
{
if (this.dataReady) {
return;
}
try {
// This reads the file and automatically detects the UTF-8 encoding.
let inputStream = new FileInputStream(new FileUtils.File(this.path),
FileUtils.MODE_RDONLY,
FileUtils.PERMS_FILE, 0)
try {
let json = Cc["@mozilla.org/dom/json;1"].createInstance(Ci.nsIJSON);
this.data = json.decodeFromStream(inputStream,
inputStream.available());
} finally {
inputStream.close();
}
} catch (ex) {
// If an exception occurred because the file did not exist, we should just
// start with new data. Other errors may indicate that the file is
// corrupt, thus we move it to a backup location before allowing it to be
// overwritten by an empty file.
if (!(ex instanceof Components.Exception &&
ex.result == Cr.NS_ERROR_FILE_NOT_FOUND)) {
Cu.reportError(ex);
// Move the original file to a backup location, ignoring errors.
try {
let originalFile = new FileUtils.File(this.path);
let backupFile = originalFile.clone();
backupFile.leafName += ".corrupt";
backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE,
FileUtils.PERMS_FILE);
backupFile.remove(false);
originalFile.moveTo(backupFile.parent, backupFile.leafName);
} catch (e2) {
Cu.reportError(e2);
}
}
// In any case, initialize a new object to host the data.
this.data = {
nextId: 1,
};
}
this._processLoadedData();
},
/**
* Synchronously work on the data just loaded into memory.
*/
_processLoadedData: function ()
{
// Create any arrays that are not present in the saved file.
if (!this.data.logins) {
this.data.logins = [];
}
if (!this.data.disabledHosts) {
this.data.disabledHosts = [];
}
// Indicate that the current version of the code has touched the file.
this.data.version = kDataVersion;
this.dataReady = true;
},
/**
* Called when the data changed, this triggers asynchronous serialization.
*/
saveSoon: function () this._saver.arm(),
/**
* DeferredTask that handles the save operation.
*/
_saver: null,
/**
* Saves persistent data from memory to the file.
*
* If an error occurs, the previous file is not deleted.
*
* @return {Promise}
* @resolves When the operation finished successfully.
* @rejects JavaScript exception.
*/
save: function ()
{
return Task.spawn(function () {
// Create or overwrite the file.
let bytes = gTextEncoder.encode(JSON.stringify(this.data));
yield OS.File.writeAtomic(this.path, bytes,
{ tmpPath: this.path + ".tmp" });
}.bind(this));
},
};

View File

@ -23,18 +23,31 @@ XPIDL_MODULE = 'loginmgr'
EXTRA_COMPONENTS += [
'crypto-SDR.js',
'nsLoginInfo.js',
'nsLoginManager.js',
'nsLoginManagerPrompter.js',
'passwordmgr.manifest',
]
EXTRA_PP_COMPONENTS += [
'storage-mozStorage.js',
'nsLoginManager.js',
'passwordmgr.manifest',
]
EXTRA_JS_MODULES += [
'InsecurePasswordUtils.jsm',
'LoginHelper.jsm',
'LoginManagerContent.jsm',
]
if CONFIG['OS_TARGET'] == 'Android':
EXTRA_COMPONENTS += [
'storage-mozStorage.js',
]
else:
EXTRA_COMPONENTS += [
'storage-json.js',
]
EXTRA_JS_MODULES += [
'LoginImport.jsm',
'LoginStore.jsm',
]
JAR_MANIFESTS += ['jar.mn']

View File

@ -125,7 +125,11 @@ LoginManager.prototype = {
_initStorage : function () {
#ifdef ANDROID
var contractID = "@mozilla.org/login-manager/storage/mozStorage;1";
#else
var contractID = "@mozilla.org/login-manager/storage/json;1";
#endif
try {
var catMan = Cc["@mozilla.org/categorymanager;1"].
getService(Ci.nsICategoryManager);

View File

@ -6,7 +6,12 @@ component {8aa66d77-1bbb-45a6-991e-b8f47751c291} nsLoginManagerPrompter.js
contract @mozilla.org/login-manager/prompter;1 {8aa66d77-1bbb-45a6-991e-b8f47751c291}
component {0f2f347c-1e4f-40cc-8efd-792dea70a85e} nsLoginInfo.js
contract @mozilla.org/login-manager/loginInfo;1 {0f2f347c-1e4f-40cc-8efd-792dea70a85e}
#ifdef ANDROID
component {8c2023b9-175c-477e-9761-44ae7b549756} storage-mozStorage.js
contract @mozilla.org/login-manager/storage/mozStorage;1 {8c2023b9-175c-477e-9761-44ae7b549756}
#else
component {c00c432d-a0c9-46d7-bef6-9c45b4d07341} storage-json.js
contract @mozilla.org/login-manager/storage/json;1 {c00c432d-a0c9-46d7-bef6-9c45b4d07341}
#endif
component {dc6c2976-0f73-4f1f-b9ff-3d72b4e28309} crypto-SDR.js
contract @mozilla.org/login-manager/crypto/SDR;1 {dc6c2976-0f73-4f1f-b9ff-3d72b4e28309}

View File

@ -0,0 +1,654 @@
/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/* vim: set sw=4 ts=4 et lcs=trail\:.,tab\:>~ : */
/* 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.
*/
////////////////////////////////////////////////////////////////////////////////
//// Globals
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
"resource://gre/modules/LoginHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginImport",
"resource://gre/modules/LoginImport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
"resource://gre/modules/LoginStore.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
////////////////////////////////////////////////////////////////////////////////
//// LoginManagerStorage_json
this.LoginManagerStorage_json = function () {}
this.LoginManagerStorage_json.prototype = {
classID : Components.ID("{c00c432d-a0c9-46d7-bef6-9c45b4d07341}"),
QueryInterface : XPCOMUtils.generateQI([Ci.nsILoginManagerStorage]),
__crypto : null, // nsILoginManagerCrypto service
get _crypto() {
if (!this.__crypto)
this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].
getService(Ci.nsILoginManagerCrypto);
return this.__crypto;
},
/*
* log
*
* Internal function for logging debug messages to the Error Console.
*/
log : function (message) {
if (!this._debug)
return;
dump("PwMgr json: " + message + "\n");
Services.console.logStringMessage("PwMgr json: " + message);
},
_debug : false,
/*
* initialize
*
*/
initialize : function () {
this._debug = Services.prefs.getBoolPref("signon.debug");
try {
// Force initialization of the crypto module.
// See bug 717490 comment 17.
this._crypto;
// Set the reference to LoginStore synchronously.
let jsonPath = OS.Path.join(OS.Constants.Path.profileDir,
"logins.json");
this._store = new LoginStore(jsonPath);
return Task.spawn(function () {
// Load the data asynchronously.
this.log("Opening database at " + this._store.path);
yield this._store.load();
// The import from previous versions operates the first time
// that this built-in storage back-end is used. This may be
// later than expected, in case add-ons have registered an
// alternate storage that disabled the default one.
try {
if (Services.prefs.getBoolPref("signon.importedFromSqlite")) {
return;
}
} catch (ex) {
// If the preference does not exist, we need to import.
}
// Import only happens asynchronously.
let sqlitePath = OS.Path.join(OS.Constants.Path.profileDir,
"signons.sqlite");
if (yield OS.File.exists(sqlitePath)) {
let loginImport = new LoginImport(this._store, sqlitePath);
// Failures during import, for example due to a corrupt
// file or a schema version that is too old, will not
// prevent us from marking the operation as completed.
// At the next startup, we will not try the import again.
yield loginImport.import().catch(Cu.reportError);
this._store.saveSoon();
}
// We won't attempt import again on next startup.
Services.prefs.setBoolPref("signon.importedFromSqlite", true);
}.bind(this)).catch(Cu.reportError);
} catch (e) {
this.log("Initialization failed: " + e);
throw "Initialization failed";
}
},
/*
* terminate
*
* Internal method used by regression tests only. It is called before
* replacing this storage module with a new instance.
*/
terminate : function () {
this._store._saver.disarm();
return this._store.save();
},
/*
* addLogin
*
*/
addLogin : function (login) {
this._store.ensureDataReady();
let encUsername, encPassword;
// Throws if there are bogus values.
LoginHelper.checkLoginValues(login);
[encUsername, encPassword, encType] = this._encryptLogin(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) {
if (!this._isGuidUnique(loginClone.guid))
throw "specified GUID already exists";
} else {
loginClone.guid = gUUIDGenerator.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.hostname,
httpRealm: loginClone.httpRealm,
formSubmitURL: loginClone.formSubmitURL,
usernameField: loginClone.usernameField,
passwordField: loginClone.passwordField,
encryptedUsername: encUsername,
encryptedPassword: encPassword,
guid: loginClone.guid,
encType: encType,
timeCreated: loginClone.timeCreated,
timeLastUsed: loginClone.timeLastUsed,
timePasswordChanged: loginClone.timePasswordChanged,
timesUsed: loginClone.timesUsed
});
this._store.saveSoon();
// Send a notification that a login was added.
this._sendNotification("addLogin", loginClone);
},
/*
* removeLogin
*
*/
removeLogin : function (login) {
this._store.ensureDataReady();
let [idToDelete, storedLogin] = this._getIdForLogin(login);
if (!idToDelete)
throw "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();
}
this._sendNotification("removeLogin", storedLogin);
},
/*
* modifyLogin
*
*/
modifyLogin : function (oldLogin, newLoginData) {
this._store.ensureDataReady();
let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin);
if (!idToModify)
throw "No matching logins";
let newLogin = LoginHelper.buildModifiedLogin(oldStoredLogin, newLoginData);
// Check if the new GUID is duplicate.
if (newLogin.guid != oldStoredLogin.guid &&
!this._isGuidUnique(newLogin.guid))
{
throw "specified GUID already exists";
}
// Look for an existing entry in case key properties changed.
if (!newLogin.matches(oldLogin, true)) {
let logins = this.findLogins({}, newLogin.hostname,
newLogin.formSubmitURL,
newLogin.httpRealm);
if (logins.some(login => newLogin.matches(login, true)))
throw "This login already exists.";
}
// 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.hostname;
loginItem.httpRealm = newLogin.httpRealm;
loginItem.formSubmitURL = newLogin.formSubmitURL;
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;
}
}
this._sendNotification("modifyLogin", [oldStoredLogin, newLogin]);
},
/*
* getAllLogins
*
* Returns an array of nsILoginInfo.
*/
getAllLogins : function (count) {
let [logins, ids] = this._searchLogins({});
// decrypt entries for caller.
logins = this._decryptLogins(logins);
this.log("_getAllLogins: returning " + logins.length + " logins.");
if (count)
count.value = logins.length; // needed for XPCOM
return logins;
},
/*
* searchLogins
*
* Public wrapper around _searchLogins to convert the nsIPropertyBag to a
* JavaScript object and decrypt the results.
*
* Returns an array of decrypted nsILoginInfo.
*/
searchLogins : function(count, matchData) {
let realMatchData = {};
// Convert nsIPropertyBag to normal JS object
let propEnum = matchData.enumerator;
while (propEnum.hasMoreElements()) {
let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
realMatchData[prop.name] = prop.value;
}
let [logins, ids] = this._searchLogins(realMatchData);
// Decrypt entries found for the caller.
logins = this._decryptLogins(logins);
count.value = logins.length; // needed for XPCOM
return logins;
},
/*
* _searchLogins
*
* 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 : function (matchData) {
this._store.ensureDataReady();
let conditions = [];
function match(aLogin) {
for (let field in matchData) {
let value = matchData[field];
switch (field) {
// Historical compatibility requires this special case
case "formSubmitURL":
if (value != null) {
if (aLogin.formSubmitURL != "" && aLogin.formSubmitURL != value) {
return false;
}
break;
}
// Normal cases.
case "hostname":
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 (value == null && aLogin[field]) {
return false;
} else if (aLogin[field] != value) {
return false;
}
break;
// Fail if caller requests an unknown property.
default:
throw "Unexpected field: " + field;
}
}
return true;
}
let foundLogins = [], foundIds = [];
for (let loginItem of this._store.data.logins) {
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("_searchLogins: returning " + foundLogins.length + " logins");
return [foundLogins, foundIds];
},
/*
* removeAllLogins
*
* Removes all logins from storage.
*
* Disabled hosts are kept, as one presumably doesn't want to erase those.
*/
removeAllLogins : function () {
this._store.ensureDataReady();
this.log("Removing all logins");
this._store.data.logins = [];
this._store.saveSoon();
this._sendNotification("removeAllLogins", null);
},
/*
* getAllDisabledHosts
*
*/
getAllDisabledHosts : function (count) {
this._store.ensureDataReady();
let disabledHosts = this._store.data.disabledHosts.slice(0);
this.log("_getAllDisabledHosts: returning " + disabledHosts.length + " disabled hosts.");
if (count)
count.value = disabledHosts.length; // needed for XPCOM
return disabledHosts;
},
/*
* getLoginSavingEnabled
*
*/
getLoginSavingEnabled : function (hostname) {
this._store.ensureDataReady();
this.log("Getting login saving is enabled for " + hostname);
return this._store.data.disabledHosts.indexOf(hostname) == -1;
},
/*
* setLoginSavingEnabled
*
*/
setLoginSavingEnabled : function (hostname, enabled) {
this._store.ensureDataReady();
// Throws if there are bogus values.
LoginHelper.checkHostnameValue(hostname);
this.log("Setting login saving enabled for " + hostname + " to " + enabled);
let foundIndex = this._store.data.disabledHosts.indexOf(hostname);
if (enabled) {
if (foundIndex != -1) {
this._store.data.disabledHosts.splice(foundIndex, 1);
this._store.saveSoon();
}
} else {
if (foundIndex == -1) {
this._store.data.disabledHosts.push(hostname);
this._store.saveSoon();
}
}
this._sendNotification(enabled ? "hostSavingEnabled" : "hostSavingDisabled", hostname);
},
/*
* findLogins
*
*/
findLogins : function (count, hostname, formSubmitURL, httpRealm) {
let loginData = {
hostname: hostname,
formSubmitURL: formSubmitURL,
httpRealm: httpRealm
};
let matchData = { };
for each (let field in ["hostname", "formSubmitURL", "httpRealm"])
if (loginData[field] != '')
matchData[field] = loginData[field];
let [logins, ids] = this._searchLogins(matchData);
// Decrypt entries found for the caller.
logins = this._decryptLogins(logins);
this.log("_findLogins: returning " + logins.length + " logins");
count.value = logins.length; // needed for XPCOM
return logins;
},
/*
* countLogins
*
*/
countLogins : function (hostname, formSubmitURL, httpRealm) {
let count = {};
let loginData = {
hostname: hostname,
formSubmitURL: formSubmitURL,
httpRealm: httpRealm
};
let matchData = { };
for each (let field in ["hostname", "formSubmitURL", "httpRealm"])
if (loginData[field] != '')
matchData[field] = loginData[field];
let [logins, ids] = this._searchLogins(matchData);
this.log("_countLogins: counted logins: " + logins.length);
return logins.length;
},
/*
* uiBusy
*/
get uiBusy() {
return this._crypto.uiBusy;
},
/*
* isLoggedIn
*/
get isLoggedIn() {
return this._crypto.isLoggedIn;
},
/*
* _sendNotification
*
* Send a notification when stored data is changed.
*/
_sendNotification : function (changeType, data) {
let dataObject = data;
// Can't pass a raw JS string or array though notifyObservers(). :-(
if (data instanceof Array) {
dataObject = Cc["@mozilla.org/array;1"].
createInstance(Ci.nsIMutableArray);
for (let i = 0; i < data.length; i++)
dataObject.appendElement(data[i], false);
} else if (typeof(data) == "string") {
dataObject = Cc["@mozilla.org/supports-string;1"].
createInstance(Ci.nsISupportsString);
dataObject.data = data;
}
Services.obs.notifyObservers(dataObject, "passwordmgr-storage-changed", changeType);
},
/*
* _getIdForLogin
*
* 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 : function (login) {
let matchData = { };
for each (let field in ["hostname", "formSubmitURL", "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];
},
/*
* _isGuidUnique
*
* Checks to see if the specified GUID already exists.
*/
_isGuidUnique : function (guid) {
this._store.ensureDataReady();
return this._store.data.logins.every(l => l.guid != guid);
},
/*
* _encryptLogin
*
* Returns the encrypted username, password, and encrypton type for the specified
* login. Can throw if the user cancels a master password entry.
*/
_encryptLogin : function (login) {
let encUsername = this._crypto.encrypt(login.username);
let encPassword = this._crypto.encrypt(login.password);
let encType = this._crypto.defaultEncType;
return [encUsername, encPassword, encType];
},
/*
* _decryptLogins
*
* 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 master password)
*/
_decryptLogins : function (logins) {
let result = [];
for each (let login in 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 master pw)
if (e.result == Cr.NS_ERROR_FAILURE)
continue;
throw e;
}
result.push(login);
}
return result;
},
};
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([LoginManagerStorage_json]);

View File

@ -13,9 +13,10 @@ const DB_VERSION = 5; // The database schema version
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
"resource://gre/modules/LoginHelper.jsm");
/**
* Object that manages a database transaction properly so consumers don't have
@ -243,7 +244,7 @@ LoginManagerStorage_mozStorage.prototype = {
let encUsername, encPassword;
// Throws if there are bogus values.
this._checkLoginValues(login);
LoginHelper.checkLoginValues(login);
[encUsername, encPassword, encType] = this._encryptLogin(login);
@ -355,109 +356,16 @@ LoginManagerStorage_mozStorage.prototype = {
let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin);
if (!idToModify)
throw "No matching logins";
oldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo);
let newLogin;
if (newLoginData instanceof Ci.nsILoginInfo) {
// Clone the existing login to get its nsILoginMetaInfo, then init it
// with the replacement nsILoginInfo data from the new login.
newLogin = oldStoredLogin.clone();
newLogin.init(newLoginData.hostname,
newLoginData.formSubmitURL, newLoginData.httpRealm,
newLoginData.username, newLoginData.password,
newLoginData.usernameField, newLoginData.passwordField);
newLogin.QueryInterface(Ci.nsILoginMetaInfo);
let newLogin = LoginHelper.buildModifiedLogin(oldStoredLogin, newLoginData);
// Automatically update metainfo when password is changed.
if (newLogin.password != oldLogin.password)
newLogin.timePasswordChanged = Date.now();
} else if (newLoginData instanceof Ci.nsIPropertyBag) {
function _bagHasProperty(aPropName) {
try {
newLoginData.getProperty(aPropName);
return true;
} catch (e) {
return false;
}
}
// Clone the existing login, along with all its properties.
newLogin = oldStoredLogin.clone();
newLogin.QueryInterface(Ci.nsILoginMetaInfo);
// Automatically update metainfo when password is changed.
// (Done before the main property updates, lest the caller be
// explicitly updating both .password and .timePasswordChanged)
if (_bagHasProperty("password")) {
let newPassword = newLoginData.getProperty("password");
if (newPassword != oldLogin.password)
newLogin.timePasswordChanged = Date.now();
}
let propEnum = newLoginData.enumerator;
while (propEnum.hasMoreElements()) {
let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
switch (prop.name) {
// nsILoginInfo properties...
case "hostname":
case "httpRealm":
case "formSubmitURL":
case "username":
case "password":
case "usernameField":
case "passwordField":
// nsILoginMetaInfo properties...
case "guid":
case "timeCreated":
case "timeLastUsed":
case "timePasswordChanged":
case "timesUsed":
newLogin[prop.name] = prop.value;
if (prop.name == "guid" && !this._isGuidUnique(newLogin.guid))
throw "specified GUID already exists";
break;
// Fake property, allows easy incrementing.
case "timesUsedIncrement":
newLogin.timesUsed += prop.value;
break;
// Fail if caller requests setting an unknown property.
default:
throw "Unexpected propertybag item: " + prop.name;
}
}
} else {
throw "newLoginData needs an expected interface!";
// Check if the new GUID is duplicate.
if (newLogin.guid != oldStoredLogin.guid &&
!this._isGuidUnique(newLogin.guid))
{
throw "specified GUID already exists";
}
// Sanity check the login
if (newLogin.hostname == null || newLogin.hostname.length == 0)
throw "Can't add a login with a null or empty hostname.";
// For logins w/o a username, set to "", not null.
if (newLogin.username == null)
throw "Can't add a login with a null username.";
if (newLogin.password == null || newLogin.password.length == 0)
throw "Can't add a login with a null or empty password.";
if (newLogin.formSubmitURL || newLogin.formSubmitURL == "") {
// We have a form submit URL. Can't have a HTTP realm.
if (newLogin.httpRealm != null)
throw "Can't add a login with both a httpRealm and formSubmitURL.";
} else if (newLogin.httpRealm) {
// We have a HTTP realm. Can't have a form submit URL.
if (newLogin.formSubmitURL != null)
throw "Can't add a login with both a httpRealm and formSubmitURL.";
} else {
// Need one or the other!
throw "Can't add a login without a httpRealm or formSubmitURL.";
}
// Throws if there are bogus values.
this._checkLoginValues(newLogin);
// Look for an existing entry in case key properties changed.
if (!newLogin.matches(oldLogin, true)) {
let logins = this.findLogins({}, newLogin.hostname,
@ -665,7 +573,6 @@ LoginManagerStorage_mozStorage.prototype = {
*
*/
storeDeletedLogin : function(aLogin) {
#ifdef ANDROID
let stmt = null;
try {
this.log("Storing " + aLogin.guid + " in deleted passwords\n");
@ -680,7 +587,6 @@ LoginManagerStorage_mozStorage.prototype = {
if (stmt)
stmt.reset();
}
#endif
},
@ -714,7 +620,7 @@ LoginManagerStorage_mozStorage.prototype = {
}
this._sendNotification("removeAllLogins", null);
},
},
/*
@ -747,7 +653,7 @@ LoginManagerStorage_mozStorage.prototype = {
*/
setLoginSavingEnabled : function (hostname, enabled) {
// Throws if there are bogus values.
this._checkHostnameValue(hostname);
LoginHelper.checkHostnameValue(hostname);
this.log("Setting login saving enabled for " + hostname + " to " + enabled);
let query;
@ -788,8 +694,8 @@ LoginManagerStorage_mozStorage.prototype = {
};
let matchData = { };
for each (let field in ["hostname", "formSubmitURL", "httpRealm"])
if (loginData[field] != '')
matchData[field] = loginData[field];
if (loginData[field] != '')
matchData[field] = loginData[field];
let [logins, ids] = this._searchLogins(matchData);
// Decrypt entries found for the caller.
@ -978,70 +884,6 @@ LoginManagerStorage_mozStorage.prototype = {
},
/*
* _checkLoginValues
*
* Due to the way the signons2.txt file is formatted, we need to make
* sure certain field values or characters do not cause the file to
* be parse incorrectly. Reject logins that we can't store correctly.
*/
_checkLoginValues : function (aLogin) {
function badCharacterPresent(l, c) {
return ((l.formSubmitURL && l.formSubmitURL.indexOf(c) != -1) ||
(l.httpRealm && l.httpRealm.indexOf(c) != -1) ||
l.hostname.indexOf(c) != -1 ||
l.usernameField.indexOf(c) != -1 ||
l.passwordField.indexOf(c) != -1);
}
// Nulls are invalid, as they don't round-trip well.
// Mostly not a formatting problem, although ".\0" can be quirky.
if (badCharacterPresent(aLogin, "\0"))
throw "login values can't contain nulls";
// In theory these nulls should just be rolled up into the encrypted
// values, but nsISecretDecoderRing doesn't use nsStrings, so the
// nulls cause truncation. Check for them here just to avoid
// unexpected round-trip surprises.
if (aLogin.username.indexOf("\0") != -1 ||
aLogin.password.indexOf("\0") != -1)
throw "login values can't contain nulls";
// Newlines are invalid for any field stored as plaintext.
if (badCharacterPresent(aLogin, "\r") ||
badCharacterPresent(aLogin, "\n"))
throw "login values can't contain newlines";
// A line with just a "." can have special meaning.
if (aLogin.usernameField == "." ||
aLogin.formSubmitURL == ".")
throw "login values can't be periods";
// A hostname with "\ \(" won't roundtrip.
// eg host="foo (", realm="bar" --> "foo ( (bar)"
// vs host="foo", realm=" (bar" --> "foo ( (bar)"
if (aLogin.hostname.indexOf(" (") != -1)
throw "bad parens in hostname";
},
/*
* _checkHostnameValue
*
* Legacy storage prohibited newlines and nulls in hostnames, so we'll keep
* that standard here. Throws on illegal format.
*/
_checkHostnameValue : function (hostname) {
// File format prohibits certain values. Also, nulls
// won't round-trip with getAllDisabledHosts().
if (hostname == "." ||
hostname.indexOf("\r") != -1 ||
hostname.indexOf("\n") != -1 ||
hostname.indexOf("\0") != -1)
throw "Invalid hostname";
},
/*
* _isGuidUnique
*

View File

@ -17,12 +17,14 @@ const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
"resource://gre/modules/DownloadPaths.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/commonjs/sdk/core/promise.js");
"resource://gre/modules/Promise.jsm");
const LoginInfo =
Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
@ -43,6 +45,46 @@ function run_test()
// Some of these functions are already implemented in other parts of the source
// tree, see bug 946708 about sharing more code.
// While the previous test file should have deleted all the temporary files it
// used, on Windows these might still be pending deletion on the physical file
// system. Thus, start from a new base number every time, to make a collision
// with a file that is still pending deletion highly unlikely.
let gFileCounter = Math.floor(Math.random() * 1000000);
/**
* Returns a reference to a temporary file, that is guaranteed not to exist, and
* to have never been created before.
*
* @param aLeafName
* Suggested leaf name for the file to be created.
*
* @return nsIFile pointing to a non-existent file in a temporary directory.
*
* @note It is not enough to delete the file if it exists, or to delete the file
* after calling nsIFile.createUnique, because on Windows the delete
* operation in the file system may still be pending, preventing a new
* file with the same name to be created.
*/
function getTempFile(aLeafName)
{
// Prepend a serial number to the extension in the suggested leaf name.
let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName);
let leafName = base + "-" + gFileCounter + ext;
gFileCounter++;
// Get a file reference under the temporary directory for this test file.
let file = FileUtils.getFile("TmpD", [leafName]);
do_check_false(file.exists());
do_register_cleanup(function () {
if (file.exists()) {
file.remove(false);
}
});
return file;
}
/**
* Allows waiting for an observer notification once.
*

View File

@ -0,0 +1,248 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Tests the LoginImport object.
*/
"use strict";
////////////////////////////////////////////////////////////////////////////////
//// Globals
Cu.import("resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
"resource://gre/modules/LoginHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginImport",
"resource://gre/modules/LoginImport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
"resource://gre/modules/LoginStore.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
"resource://gre/modules/Sqlite.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "gLoginManagerCrypto",
"@mozilla.org/login-manager/crypto/SDR;1",
"nsILoginManagerCrypto");
XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
/**
* Creates empty login data tables in the given SQLite connection, resembling
* the most recent schema version (excluding indices).
*/
function promiseCreateDatabaseSchema(aConnection)
{
return Task.spawn(function () {
yield aConnection.setSchemaVersion(5);
yield aConnection.execute("CREATE TABLE moz_logins (" +
"id INTEGER PRIMARY KEY," +
"hostname TEXT NOT NULL," +
"httpRealm TEXT," +
"formSubmitURL TEXT," +
"usernameField TEXT NOT NULL," +
"passwordField TEXT NOT NULL," +
"encryptedUsername TEXT NOT NULL," +
"encryptedPassword TEXT NOT NULL," +
"guid TEXT," +
"encType INTEGER," +
"timeCreated INTEGER," +
"timeLastUsed INTEGER," +
"timePasswordChanged INTEGER," +
"timesUsed INTEGER)");
yield aConnection.execute("CREATE TABLE moz_disabledHosts (" +
"id INTEGER PRIMARY KEY," +
"hostname TEXT UNIQUE)");
yield aConnection.execute("CREATE TABLE moz_deleted_logins (" +
"id INTEGER PRIMARY KEY," +
"guid TEXT," +
"timeDeleted INTEGER)");
});
}
/**
* Inserts a new entry in the database resembling the given nsILoginInfo object.
*/
function promiseInsertLoginInfo(aConnection, aLoginInfo)
{
aLoginInfo.QueryInterface(Ci.nsILoginMetaInfo);
// We can't use the aLoginInfo object directly in the execute statement
// because the bind code in Sqlite.jsm doesn't allow objects with extra
// properties beyond those being binded. So we might as well use an array as
// it is simpler.
let values = [
aLoginInfo.hostname,
aLoginInfo.httpRealm,
aLoginInfo.formSubmitURL,
aLoginInfo.usernameField,
aLoginInfo.passwordField,
gLoginManagerCrypto.encrypt(aLoginInfo.username),
gLoginManagerCrypto.encrypt(aLoginInfo.password),
aLoginInfo.guid,
aLoginInfo.encType,
aLoginInfo.timeCreated,
aLoginInfo.timeLastUsed,
aLoginInfo.timePasswordChanged,
aLoginInfo.timesUsed,
];
return aConnection.execute("INSERT INTO moz_logins (hostname, " +
"httpRealm, formSubmitURL, usernameField, " +
"passwordField, encryptedUsername, " +
"encryptedPassword, guid, encType, timeCreated, " +
"timeLastUsed, timePasswordChanged, timesUsed) " +
"VALUES (?" + ",?".repeat(12) + ")", values);
}
/**
* Inserts a new disabled host entry in the database.
*/
function promiseInsertDisabledHost(aConnection, aHostname)
{
return aConnection.execute("INSERT INTO moz_disabledHosts (hostname) " +
"VALUES (?)", [aHostname]);
}
////////////////////////////////////////////////////////////////////////////////
//// Tests
/**
* Imports login data from a SQLite file constructed using the test data.
*/
add_task(function test_import()
{
let store = new LoginStore(getTempFile("test-import.json").path);
let loginsSqlite = getTempFile("test-logins.sqlite").path;
// Prepare the logins to be imported, including the nsILoginMetaInfo data.
let loginList = TestData.loginList();
for (let loginInfo of loginList) {
loginInfo.QueryInterface(Ci.nsILoginMetaInfo);
loginInfo.guid = gUUIDGenerator.generateUUID().toString();
loginInfo.timeCreated = Date.now();
loginInfo.timeLastUsed = Date.now();
loginInfo.timePasswordChanged = Date.now();
loginInfo.timesUsed = 1;
}
// Create and populate the SQLite database first.
let connection = yield Sqlite.openConnection({ path: loginsSqlite });
try {
yield promiseCreateDatabaseSchema(connection);
for (let loginInfo of loginList) {
yield promiseInsertLoginInfo(connection, loginInfo);
}
yield promiseInsertDisabledHost(connection, "http://www.example.com");
yield promiseInsertDisabledHost(connection, "https://www.example.org");
} finally {
yield connection.close();
}
// The "load" method must be called before importing data.
yield store.load();
yield new LoginImport(store, loginsSqlite).import();
// Verify that every login in the test data has a matching imported row.
do_check_eq(loginList.length, store.data.logins.length);
do_check_true(loginList.every(function (loginInfo) {
return store.data.logins.some(function (loginDataItem) {
let username = gLoginManagerCrypto.decrypt(loginDataItem.encryptedUsername);
let password = gLoginManagerCrypto.decrypt(loginDataItem.encryptedPassword);
return loginDataItem.hostname == loginInfo.hostname &&
loginDataItem.httpRealm == loginInfo.httpRealm &&
loginDataItem.formSubmitURL == loginInfo.formSubmitURL &&
loginDataItem.usernameField == loginInfo.usernameField &&
loginDataItem.passwordField == loginInfo.passwordField &&
username == loginInfo.username &&
password == loginInfo.password &&
loginDataItem.guid == loginInfo.guid &&
loginDataItem.encType == loginInfo.encType &&
loginDataItem.timeCreated == loginInfo.timeCreated &&
loginDataItem.timeLastUsed == loginInfo.timeLastUsed &&
loginDataItem.timePasswordChanged == loginInfo.timePasswordChanged &&
loginDataItem.timesUsed == loginInfo.timesUsed;
});
}));
// Verify that disabled hosts have been imported.
do_check_eq(store.data.disabledHosts.length, 2);
do_check_true(store.data.disabledHosts.some(
dataItem => dataItem.hostname == "http://www.example.com"));
do_check_true(store.data.disabledHosts.some(
dataItem => dataItem.hostname == "https://www.example.org"));
});
/**
* Tests imports of NULL values due to a downgraded database.
*/
add_task(function test_import_downgraded()
{
let store = new LoginStore(getTempFile("test-import-downgraded.json").path);
let loginsSqlite = getTempFile("test-logins-downgraded.sqlite").path;
let loginList = TestData.loginList();
// Create and populate the SQLite database first.
let connection = yield Sqlite.openConnection({ path: loginsSqlite });
try {
yield promiseCreateDatabaseSchema(connection);
yield connection.setSchemaVersion(3);
yield promiseInsertLoginInfo(connection, TestData.formLogin({
guid: gUUIDGenerator.generateUUID().toString(),
timeCreated: null,
timeLastUsed: null,
timePasswordChanged: null,
timesUsed: 0,
}));
} finally {
yield connection.close();
}
// The "load" method must be called before importing data.
yield store.load();
yield new LoginImport(store, loginsSqlite).import();
// Verify that the missing metadata was generated correctly.
let loginItem = store.data.logins[0];
let creationTime = loginItem.timeCreated;
LoginTest.assertTimeIsAboutNow(creationTime);
do_check_eq(loginItem.timeLastUsed, creationTime);
do_check_eq(loginItem.timePasswordChanged, creationTime);
do_check_eq(loginItem.timesUsed, 1);
});
/**
* Verifies that importing from a SQLite file with database version 2 fails.
*/
add_task(function test_import_v2()
{
let store = new LoginStore(getTempFile("test-import-v2.json").path);
let loginsSqlite = do_get_file("data/signons-v2.sqlite").path;
// The "load" method must be called before importing data.
yield store.load();
try {
yield new LoginImport(store, loginsSqlite).import();
do_throw("The operation should have failed.");
} catch (ex) { }
});
/**
* Imports login data from a SQLite file, with database version 3.
*/
add_task(function test_import_v3()
{
let store = new LoginStore(getTempFile("test-import-v3.json").path);
let loginsSqlite = do_get_file("data/signons-v3.sqlite").path;
// The "load" method must be called before importing data.
yield store.load();
yield new LoginImport(store, loginsSqlite).import();
// We only execute basic integrity checks.
do_check_eq(store.data.logins[0].usernameField, "u1");
do_check_eq(store.data.disabledHosts.length, 0);
});

View File

@ -0,0 +1,208 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Tests the LoginStore object.
*/
"use strict";
////////////////////////////////////////////////////////////////////////////////
//// Globals
XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
"resource://gre/modules/LoginStore.jsm");
const TEST_STORE_FILE_NAME = "test-logins.json";
////////////////////////////////////////////////////////////////////////////////
//// Tests
/**
* Saves login data to a file, then reloads it.
*/
add_task(function test_save_reload()
{
let storeForSave = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
// The "load" method must be called before preparing the data to be saved.
yield storeForSave.load();
let rawLoginData = {
id: storeForSave.data.nextId++,
hostname: "http://www.example.com",
httpRealm: null,
formSubmitURL: "http://www.example.com/submit-url",
usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345),
passwordField: "field_" + String.fromCharCode(421, 259, 349, 537),
encryptedUsername: "(test)",
encryptedPassword: "(test)",
guid: "(test)",
encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
timeCreated: Date.now(),
timeLastUsed: Date.now(),
timePasswordChanged: Date.now(),
timesUsed: 1,
};
storeForSave.data.logins.push(rawLoginData);
storeForSave.data.disabledHosts.push("http://www.example.org");
yield storeForSave.save();
// Test the asynchronous initialization path.
let storeForLoad = new LoginStore(storeForSave.path);
yield storeForLoad.load();
do_check_eq(storeForLoad.data.logins.length, 1);
do_check_matches(storeForLoad.data.logins[0], rawLoginData);
do_check_eq(storeForLoad.data.disabledHosts.length, 1);
do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org");
// Test the synchronous initialization path.
storeForLoad = new LoginStore(storeForSave.path);
storeForLoad.ensureDataReady();
do_check_eq(storeForLoad.data.logins.length, 1);
do_check_matches(storeForLoad.data.logins[0], rawLoginData);
do_check_eq(storeForLoad.data.disabledHosts.length, 1);
do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org");
});
/**
* Checks that loading from a missing file results in empty arrays.
*/
add_task(function test_load_empty()
{
let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
do_check_false(yield OS.File.exists(store.path));
yield store.load();
do_check_false(yield OS.File.exists(store.path));
do_check_eq(store.data.logins.length, 0);
do_check_eq(store.data.disabledHosts.length, 0);
});
/**
* Checks that saving empty data still overwrites any existing file.
*/
add_task(function test_save_empty()
{
let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
yield store.load();
let createdFile = yield OS.File.open(store.path, { create: true });
yield createdFile.close();
yield store.save();
do_check_true(yield OS.File.exists(store.path));
});
/**
* Loads data from a string in a predefined format. The purpose of this test is
* to verify that the JSON format used in previous versions can be loaded.
*/
add_task(function test_load_string_predefined()
{
let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
let string = "{\"logins\":[{" +
"\"id\":1," +
"\"hostname\":\"http://www.example.com\"," +
"\"httpRealm\":null," +
"\"formSubmitURL\":\"http://www.example.com/submit-url\"," +
"\"usernameField\":\"usernameField\"," +
"\"passwordField\":\"passwordField\"," +
"\"encryptedUsername\":\"(test)\"," +
"\"encryptedPassword\":\"(test)\"," +
"\"guid\":\"(test)\"," +
"\"encType\":1," +
"\"timeCreated\":1262304000000," +
"\"timeLastUsed\":1262390400000," +
"\"timePasswordChanged\":1262476800000," +
"\"timesUsed\":1}],\"disabledHosts\":[" +
"\"http://www.example.org\"]}";
yield OS.File.writeAtomic(store.path,
new TextEncoder().encode(string),
{ tmpPath: store.path + ".tmp" });
yield store.load();
do_check_eq(store.data.logins.length, 1);
do_check_matches(store.data.logins[0], {
id: 1,
hostname: "http://www.example.com",
httpRealm: null,
formSubmitURL: "http://www.example.com/submit-url",
usernameField: "usernameField",
passwordField: "passwordField",
encryptedUsername: "(test)",
encryptedPassword: "(test)",
guid: "(test)",
encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
timeCreated: 1262304000000,
timeLastUsed: 1262390400000,
timePasswordChanged: 1262476800000,
timesUsed: 1,
});
do_check_eq(store.data.disabledHosts.length, 1);
do_check_eq(store.data.disabledHosts[0], "http://www.example.org");
});
/**
* Loads login data from a malformed JSON string.
*/
add_task(function test_load_string_malformed()
{
let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," +
"\"id\":1,";
yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
{ tmpPath: store.path + ".tmp" });
yield store.load();
// A backup file should have been created.
do_check_true(yield OS.File.exists(store.path + ".corrupt"));
yield OS.File.remove(store.path + ".corrupt");
// The store should be ready to accept new data.
do_check_eq(store.data.logins.length, 0);
do_check_eq(store.data.disabledHosts.length, 0);
});
/**
* Loads login data from a malformed JSON string, using the synchronous
* initialization path.
*/
add_task(function test_load_string_malformed_sync()
{
let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," +
"\"id\":1,";
yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
{ tmpPath: store.path + ".tmp" });
store.ensureDataReady();
// A backup file should have been created.
do_check_true(yield OS.File.exists(store.path + ".corrupt"));
yield OS.File.remove(store.path + ".corrupt");
// The store should be ready to accept new data.
do_check_eq(store.data.logins.length, 0);
do_check_eq(store.data.disabledHosts.length, 0);
});

View File

@ -3,6 +3,17 @@ head = head.js
tail =
support-files = data/**
# Test JSON file access and import from SQLite, not applicable to Android.
[test_module_LoginImport.js]
skip-if = os == "android"
[test_module_LoginStore.js]
skip-if = os == "android"
# Test SQLite database backup and migration, applicable to Android only.
[test_storage_mozStorage.js]
skip-if = os != "android"
# The following tests apply to any storage back-end.
[test_disabled_hosts.js]
[test_legacy_empty_formSubmitURL.js]
[test_legacy_validation.js]
@ -12,4 +23,3 @@ support-files = data/**
[test_logins_search.js]
[test_notifications.js]
[test_storage.js]
[test_storage_mozStorage.js]