/* 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/. */ /** * Helpers for using OS Key Store. */ "use strict"; var EXPORTED_SYMBOLS = ["OSKeyStore"]; const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "AppConstants", "resource://gre/modules/AppConstants.jsm" ); XPCOMUtils.defineLazyServiceGetter( this, "nativeOSKeyStore", "@mozilla.org/security/oskeystore;1", Ci.nsIOSKeyStore ); XPCOMUtils.defineLazyServiceGetter( this, "osReauthenticator", "@mozilla.org/security/osreauthenticator;1", Ci.nsIOSReauthenticator ); // Skip reauth during tests, only works in non-official builds. const TEST_ONLY_REAUTH = "extensions.formautofill.osKeyStore.unofficialBuildOnlyLogin"; var OSKeyStore = { /** * On macOS this becomes part of the name label visible on Keychain Acesss as * "org.mozilla.nss.keystore.firefox" (where "firefox" is the MOZ_APP_NAME). */ STORE_LABEL: AppConstants.MOZ_APP_NAME, /** * Consider the module is initialized as locked. OS might unlock without a * prompt. * @type {Boolean} */ _isLocked: true, _pendingUnlockPromise: null, /** * @returns {boolean} True if logged in (i.e. decrypt(reauth = false) will * not retrigger a dialog) and false if not. * User might log out elsewhere in the OS, so even if this * is true a prompt might still pop up. */ get isLoggedIn() { return !this._isLocked; }, /** * @returns {boolean} True if there is another login dialog existing and false * otherwise. */ get isUIBusy() { return !!this._pendingUnlockPromise; }, /** * If the test pref exists, this method will dispatch a observer message and * resolves to simulate successful reauth, or rejects to simulate failed reauth. * * @returns {Promise} Resolves when sucessful login, rejects when * login fails. */ async _reauthInTests() { // Skip this reauth because there is no way to mock the // native dialog in the testing environment, for now. log.debug("_ensureReauth: _testReauth: ", this._testReauth); switch (this._testReauth) { case "pass": Services.obs.notifyObservers( null, "oskeystore-testonly-reauth", "pass" ); break; case "cancel": Services.obs.notifyObservers( null, "oskeystore-testonly-reauth", "cancel" ); throw new Components.Exception( "Simulating user cancelling login dialog", Cr.NS_ERROR_FAILURE ); default: throw new Components.Exception( "Unknown test pref value", Cr.NS_ERROR_FAILURE ); } }, /** * Ensure the store in use is logged in. It will display the OS login * login prompt or do nothing if it's logged in already. If an existing login * prompt is already prompted, the result from it will be used instead. * * Note: This method must set _pendingUnlockPromise before returning the * promise (i.e. the first |await|), otherwise we'll risk re-entry. * This is why there aren't an |await| in the method. The method is marked as * |async| to communicate that it's async. * * @param {boolean|string} reauth If it's set to true or a string, prompt * the reauth login dialog. * The string will be shown on the native OS * login dialog. * @returns {Promise} 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 ensureLoggedIn(reauth = false) { if (this._pendingUnlockPromise) { log.debug("ensureLoggedIn: Has a pending unlock operation"); return this._pendingUnlockPromise; } log.debug( "ensureLoggedIn: Creating new pending unlock promise. reauth: ", reauth ); let unlockPromise; // Decides who should handle reauth if (!this._reauthEnabledByUser || (typeof reauth == "boolean" && !reauth)) { unlockPromise = Promise.resolve(); } else if (!AppConstants.MOZILLA_OFFICIAL && this._testReauth) { unlockPromise = this._reauthInTests(); } else if ( AppConstants.platform == "win" || AppConstants.platform == "macosx" ) { let reauthLabel = typeof reauth == "string" ? reauth : ""; // On Windows, this promise rejects when the user cancels login dialog, see bug 1502121. // On macOS this resolves to false, so we would need to check it. unlockPromise = osReauthenticator .asyncReauthenticateUser(reauthLabel) .then(reauthResult => { if (typeof reauthResult == "boolean" && !reauthResult) { throw new Components.Exception( "User canceled OS reauth entry", Cr.NS_ERROR_FAILURE ); } }); } else { log.debug("ensureLoggedIn: Skipping reauth on unsupported platforms"); unlockPromise = Promise.resolve(); } unlockPromise = unlockPromise.then(async () => { if (!(await nativeOSKeyStore.asyncSecretAvailable(this.STORE_LABEL))) { log.debug( "ensureLoggedIn: Secret unavailable, attempt to generate new secret." ); let recoveryPhrase = await nativeOSKeyStore.asyncGenerateSecret( this.STORE_LABEL ); // TODO We should somehow have a dialog to ask the user to write this down, // and another dialog somewhere for the user to restore the secret with it. // (Intentionally not printing it out in the console) log.debug( "ensureLoggedIn: Secret generated. Recovery phrase length: " + recoveryPhrase.length ); } }); unlockPromise = unlockPromise.then( () => { log.debug("ensureLoggedIn: Logged in"); this._pendingUnlockPromise = null; this._isLocked = false; return true; }, err => { log.debug("ensureLoggedIn: Not logged in", err); this._pendingUnlockPromise = null; this._isLocked = true; return false; } ); this._pendingUnlockPromise = unlockPromise; return this._pendingUnlockPromise; }, /** * Decrypts cipherText. * * Note: In the event of an rejection, check the result property of the Exception * object. Handles NS_ERROR_ABORT as user has cancelled the action (e.g., * don't show that dialog), apart from other errors (e.g., gracefully * recover from that and still shows the dialog.) * * @param {string} cipherText Encrypted string including the algorithm details. * @param {boolean|string} reauth If it's set to true or a string, prompt * the reauth login dialog. * The string may be shown on the native OS * login dialog. * @returns {Promise} resolves to the decrypted string, or rejects otherwise. */ async decrypt(cipherText, reauth = false) { if (!(await this.ensureLoggedIn(reauth))) { throw Components.Exception( "User canceled OS unlock entry", Cr.NS_ERROR_ABORT ); } let bytes = await nativeOSKeyStore.asyncDecryptBytes( this.STORE_LABEL, cipherText ); return String.fromCharCode.apply(String, bytes); }, /** * Encrypts a string and returns cipher text containing algorithm information used for decryption. * * @param {string} plainText Original string without encryption. * @returns {Promise} resolves to the encrypted string (with algorithm), otherwise rejects. */ async encrypt(plainText) { if (!(await this.ensureLoggedIn())) { throw Components.Exception( "User canceled OS unlock entry", Cr.NS_ERROR_ABORT ); } // Convert plain text into a UTF-8 binary string plainText = unescape(encodeURIComponent(plainText)); // Convert it to an array let textArr = []; for (let char of plainText) { textArr.push(char.charCodeAt(0)); } let rawEncryptedText = await nativeOSKeyStore.asyncEncryptBytes( this.STORE_LABEL, textArr ); // Mark the output with a version number. return rawEncryptedText; }, /** * Resolve when the login dialogs are closed, immediately if none are open. * * An existing MP dialog will be focused and will request attention. * * @returns {Promise} * Resolves with whether the user is logged in to MP. */ async waitForExistingDialog() { if (this.isUIBusy) { return this._pendingUnlockPromise; } return this.isLoggedIn; }, /** * Remove the store. For tests. */ async cleanup() { return nativeOSKeyStore.asyncDeleteSecret(this.STORE_LABEL); }, }; XPCOMUtils.defineLazyGetter(this, "log", () => { let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}) .ConsoleAPI; return new ConsoleAPI({ maxLogLevelPref: "extensions.formautofill.loglevel", prefix: "OSKeyStore", }); }); XPCOMUtils.defineLazyPreferenceGetter( OSKeyStore, "_testReauth", TEST_ONLY_REAUTH, "" ); XPCOMUtils.defineLazyPreferenceGetter( OSKeyStore, "_reauthEnabledByUser", "extensions.formautofill.reauth.enabled", false );