Bug 1390433 - (From 1388238)Add encrypt/decrypt methods to MasterPassword.jsm. r=MattN
MozReview-Commit-ID: ICXnhPEoNK4
--- 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]