Bug 1388238 - Add encrypt/decrypt methods to MasterPassword.jsm. r=MattN
authorSteve Chung <schung@mozilla.com>
Fri, 18 Aug 2017 15:10:35 +0800
changeset 423770 d0ea76640bfd864ac3638fe5e99a73ddd0d02f97
parent 423769 966d4487c72ec7ac5a386cb7f4e28695c9e05c8e
child 423771 22b871a800881d7b2951e8eb0197420046f3b63a
push id1517
push userjlorenzo@mozilla.com
push dateThu, 14 Sep 2017 16:50:54 +0000
treeherdermozilla-release@3b41fd564418 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1388238
milestone56.0
Bug 1388238 - Add encrypt/decrypt methods to MasterPassword.jsm. r=MattN MozReview-Commit-ID: ICXnhPEoNK4
browser/extensions/formautofill/MasterPassword.jsm
browser/extensions/formautofill/test/unit/test_masterPassword.js
browser/extensions/formautofill/test/unit/xpcshell.ini
--- 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,115 @@
+/**
+ * Tests of MasterPassword.jsm
+ */
+
+"use strict";
+const {MockRegistrar} =
+  Cu.import("resource://testing-common/MockRegistrar.jsm", {});
+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,
+}];
+
+
+// Tests that PSM can successfully ask for a password from the user and relay it
+// back to NSS. Does so by mocking out the actual dialog and "filling in" the
+// password. Also tests that providing an incorrect password will fail (well,
+// technically the user will just get prompted again, but if they then cancel
+// the dialog the overall operation will fail).
+
+let gMockPrompter = {
+  passwordToTry: null,
+  numPrompts: 0,
+
+  // This intentionally does not use arrow function syntax to avoid an issue
+  // where in the context of the arrow function, |this != gMockPrompter| due to
+  // how objects get wrapped when going across xpcom boundaries.
+  promptPassword(dialogTitle, text, password, checkMsg, checkValue) {
+    this.numPrompts++;
+    if (this.numPrompts > 1) { // don't keep retrying a bad password
+      return false;
+    }
+    equal(text,
+          "Please enter the master password for the Software Security Device.",
+          "password prompt text should be as expected");
+    equal(checkMsg, null, "checkMsg should be null");
+    ok(this.passwordToTry, "passwordToTry should be non-null");
+    password.value = this.passwordToTry;
+    return true;
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIPrompt]),
+};
+
+// Mock nsIWindowWatcher. PSM calls getNewPrompter on this to get an nsIPrompt
+// to call promptPassword. We return the mock one, above.
+let gWindowWatcher = {
+  getNewPrompter: () => gMockPrompter,
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIWindowWatcher]),
+};
+
+// Ensure that the appropriate initialization has happened.
+do_get_profile();
+
+let windowWatcherCID =
+  MockRegistrar.register("@mozilla.org/embedcomp/window-watcher;1",
+                         gWindowWatcher);
+do_register_cleanup(() => {
+  MockRegistrar.unregister(windowWatcherCID);
+});
+
+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()) {
+      // Reset state.
+      gMockPrompter.numPrompts = 0;
+      token.logoutSimple();
+
+      ok(!token.isLoggedIn(),
+         "Token should be logged out after calling logoutSimple()");
+
+      // Try with the correct password.
+      gMockPrompter.passwordToTry = testcase.masterPassword;
+      await MasterPassword.encrypt(testText);
+      Assert.equal(gMockPrompter.numPrompts, 1, "should have prompted for encryption");
+
+      // Reset state.
+      gMockPrompter.numPrompts = 0;
+      token.logoutSimple();
+
+      try {
+        // Try with the incorrect password.
+        gMockPrompter.passwordToTry = "XXX";
+        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
@@ -27,16 +27,17 @@ support-files =
 [test_getCategoriesFromFieldNames.js]
 [test_getFormInputDetails.js]
 [test_getInfo.js]
 [test_getRecords.js]
 [test_isAvailable.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]