Bug 1388238 - Add encrypt/decrypt methods to MasterPassword.jsm. r=MattN
MozReview-Commit-ID: AHpzYNbnnWv
--- a/browser/extensions/formautofill/MasterPassword.jsm
+++ b/browser/extensions/formautofill/MasterPassword.jsm
@@ -13,18 +13,107 @@ this.EXPORTED_SYMBOLS = [
"MasterPassword",
];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "cryptoSDR",
+ "@mozilla.org/login-manager/crypto/SDR;1",
+ Ci.nsILoginManagerCrypto);
this.MasterPassword = {
+ get _token() {
+ let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(Ci.nsIPK11TokenDB);
+ return tokendb.getInternalKeyToken();
+ },
+
+ /**
+ * @returns {boolean} True if a master password is set and false otherwise.
+ */
+ get isEnabled() {
+ return this._token.hasPassword;
+ },
+
+ /**
+ * Display the master password login prompt no matter it's logged in or not.
+ * If an existing MP prompt is already open, the result from it will be used instead.
+ *
+ * @returns {Promise<boolean>} True if it's logged in or no password is set and false
+ * if it's still not logged in (prompt canceled or other error).
+ */
+ async prompt() {
+ if (!this.isEnabled) {
+ return true;
+ }
+
+ // If a prompt is already showing then wait for and focus it.
+ if (Services.logins.uiBusy) {
+ return this.waitForExistingDialog();
+ }
+
+ let token = this._token;
+ try {
+ // 'true' means always prompt for token password. User will be prompted until
+ // clicking 'Cancel' or entering the correct password.
+ token.login(true);
+ } catch (e) {
+ // An exception will be thrown if the user cancels the login prompt dialog.
+ // User is also logged out.
+ }
+
+ // If we triggered a master password prompt, notify observers.
+ if (token.isLoggedIn()) {
+ Services.obs.notifyObservers(null, "passwordmgr-crypto-login");
+ } else {
+ Services.obs.notifyObservers(null, "passwordmgr-crypto-loginCanceled");
+ }
+
+ return token.isLoggedIn();
+ },
+
+ /**
+ * Decrypts cipherText.
+ *
+ * @param {string} cipherText Encrypted string including the algorithm details.
+ * @param {boolean} reauth True if we want to force the prompt to show up
+ * even if the user is already logged in.
+ * @returns {Promise<string>} resolves to the decrypted string, or rejects otherwise.
+ */
+ async decrypt(cipherText, reauth = false) {
+ let loggedIn = false;
+ if (reauth) {
+ loggedIn = await this.prompt();
+ } else {
+ loggedIn = await this.waitForExistingDialog();
+ }
+
+ if (!loggedIn) {
+ throw Components.Exception("User canceled master password entry", Cr.NS_ERROR_ABORT);
+ }
+
+ return cryptoSDR.decrypt(cipherText);
+ },
+
+ /**
+ * Encrypts a string and returns cipher text containing algorithm information used for decryption.
+ *
+ * @param {string} plainText Original string without encryption.
+ * @returns {Promise<string>} resolves to the encrypted string (with algorithm), otherwise rejects.
+ */
+ async encrypt(plainText) {
+ if (Services.logins.uiBusy && !await this.waitForExistingDialog()) {
+ throw Components.Exception("User canceled master password entry", Cr.NS_ERROR_ABORT);
+ }
+
+ return cryptoSDR.encrypt(plainText);
+ },
+
/**
* Resolve when master password dialogs are closed, immediately if none are open.
*
* An existing MP dialog will be focused and will request attention.
*
* @returns {Promise<boolean>}
* Resolves with whether the user is logged in to MP.
*/
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_masterPassword.js
@@ -0,0 +1,62 @@
+/**
+ * Tests of MasterPassword.jsm
+ */
+
+"use strict";
+
+let {MasterPassword} = Cu.import("resource://formautofill/MasterPassword.jsm", {});
+
+const TESTCASES = [{
+ description: "With master password set",
+ masterPassword: "fakemp",
+ mpEnabled: true,
+},
+{
+ description: "Without master password set",
+ masterPassword: "", // "" means no master password
+ mpEnabled: false,
+}];
+
+add_task(async function test_without_init_crypto() {
+ MasterPassword._token.reset();
+ Assert.equal(MasterPassword._token.needsUserInit, true);
+});
+
+TESTCASES.forEach(testcase => {
+ let token = MasterPassword._token;
+
+ add_task(async function test_encrypt_decrypt() {
+ do_print("Starting testcase: " + testcase.description);
+ token.initPassword(testcase.masterPassword);
+
+ // Test only: Force the token login without asking for master password
+ token.login(/* force */ false);
+ Assert.equal(testcase.mpEnabled, token.isLoggedIn(), "Token should now be logged into");
+ Assert.equal(MasterPassword.isEnabled, testcase.mpEnabled);
+
+ let testText = "test string";
+ let cipherText = await MasterPassword.encrypt(testText);
+ Assert.notEqual(testText, cipherText);
+ let plainText = await MasterPassword.decrypt(cipherText);
+ Assert.equal(testText, plainText);
+ if (token.isLoggedIn()) {
+ token.logoutSimple();
+ ok(!token.isLoggedIn(),
+ "Token should be logged out after calling logoutSimple()");
+ try {
+ await MasterPassword.encrypt(testText);
+ throw new Error("Not receiving canceled master password error");
+ } catch (e) {
+ Assert.equal(e.message, "User canceled master password entry");
+ }
+ try {
+ await MasterPassword.decrypt(cipherText);
+ throw new Error("Not receiving canceled master password error");
+ } catch (e) {
+ Assert.equal(e.message, "User canceled master password entry");
+ }
+ }
+
+ token.reset();
+ });
+});
--- a/browser/extensions/formautofill/test/unit/xpcshell.ini
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -26,16 +26,17 @@ support-files =
[test_getAdaptedProfiles.js]
[test_getCategoriesFromFieldNames.js]
[test_getFormInputDetails.js]
[test_getInfo.js]
[test_getRecords.js]
[test_isCJKName.js]
[test_isFieldEligibleForAutofill.js]
[test_markAsAutofillField.js]
+[test_masterPassword.js]
[test_migrateRecords.js]
[test_nameUtils.js]
[test_onFormSubmitted.js]
[test_profileAutocompleteResult.js]
[test_phoneNumber.js]
[test_reconcile.js]
[test_savedFieldNames.js]
[test_toOneLineAddress.js]