diff options
Diffstat (limited to 'toolkit/modules/OSKeyStore.sys.mjs')
-rw-r--r-- | toolkit/modules/OSKeyStore.sys.mjs | 382 |
1 files changed, 382 insertions, 0 deletions
diff --git a/toolkit/modules/OSKeyStore.sys.mjs b/toolkit/modules/OSKeyStore.sys.mjs new file mode 100644 index 0000000000..3d759ed740 --- /dev/null +++ b/toolkit/modules/OSKeyStore.sys.mjs @@ -0,0 +1,382 @@ +/* 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. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "nativeOSKeyStore", + "@mozilla.org/security/oskeystore;1", + Ci.nsIOSKeyStore +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "osReauthenticator", + "@mozilla.org/security/osreauthenticator;1", + Ci.nsIOSReauthenticator +); + +// Skip reauth during tests, only works in non-official builds. +const TEST_ONLY_REAUTH = "toolkit.osKeyStore.unofficialBuildOnlyLogin"; + +export var OSKeyStore = { + /** + * On macOS this becomes part of the name label visible on Keychain Acesss as + * "Firefox Encrypted Storage" (where "Firefox" is the MOZ_APP_BASENAME). + * Unfortunately, since this is the index into the keystore, we can't + * localize it without some really unfortunate side effects, like users + * losing access to stored information when they change their locale. + * This is a limitation of the interface exposed by macOS. Notably, both + * Chrome and Safari suffer the same shortcoming. + */ + STORE_LABEL: AppConstants.MOZ_APP_BASENAME + " Encrypted Storage", + + /** + * 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; + }, + + canReauth() { + // We have no support on linux (bug 1527745) + if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + lazy.log.debug( + "canReauth, returning true, this._testReauth:", + this._testReauth + ); + return true; + } + lazy.log.debug("canReauth, returning false"); + return false; + }, + + /** + * 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<undefined>} 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. + lazy.log.debug("_reauthInTests: _testReauth: ", this._testReauth); + switch (this._testReauth) { + case "pass": + Services.obs.notifyObservers( + null, + "oskeystore-testonly-reauth", + "pass" + ); + return { authenticated: true, auth_details: "success" }; + 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 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 set to a string, prompt the reauth login dialog, + * showing the string on the native OS login dialog. + * Otherwise `false` will prevent showing the prompt. + * @param {string} dialogCaption The string will be shown on the native OS + * login dialog as the dialog caption (usually Product Name). + * @param {Window?} parentWindow The window of the caller, used to center the + * OS prompt in the middle of the application window. + * @param {boolean} generateKeyIfNotAvailable Makes key generation optional + * because it will currently cause more + * problems for us down the road on macOS since the application + * that creates the Keychain item is the only one that gets + * access to the key in the future and right now that key isn't + * specific to the channel or profile. This means if a user uses + * both DevEdition and Release on the same OS account (not + * unreasonable for a webdev.) then when you want to simply + * re-auth the user for viewing passwords you may also get a + * KeyChain prompt to allow the app to access the stored key even + * though that's not at all relevant for the re-auth. We skip the + * code here so that we can postpone deciding on how we want to + * handle this problem (multiple channels) until we actually use + * the key storage. If we start creating keys on macOS by running + * this code we'll potentially have to do extra work to cleanup + * the mess later. + * @returns {Promise<Object>} Object with the following properties: + * authenticated: {boolean} Set to true if the user successfully authenticated. + * auth_details: {String?} Details of the authentication result. + */ + async ensureLoggedIn( + reauth = false, + dialogCaption = "", + parentWindow = null, + generateKeyIfNotAvailable = true + ) { + if ( + (typeof reauth != "boolean" && typeof reauth != "string") || + reauth === true || + reauth === "" + ) { + throw new Error( + "reauth is required to either be `false` or a non-empty string" + ); + } + + if (this._pendingUnlockPromise) { + lazy.log.debug("ensureLoggedIn: Has a pending unlock operation"); + return this._pendingUnlockPromise; + } + lazy.log.debug( + "ensureLoggedIn: Creating new pending unlock promise. reauth: ", + reauth + ); + + let unlockPromise; + if (typeof reauth == "string") { + // Only allow for local builds + if ( + lazy.UpdateUtils.getUpdateChannel(false) == "default" && + this._testReauth + ) { + unlockPromise = this._reauthInTests(); + } else if (this.canReauth()) { + // 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 = lazy.osReauthenticator + .asyncReauthenticateUser(reauth, dialogCaption, parentWindow) + .then(reauthResult => { + let auth_details_extra = {}; + if (reauthResult.length > 3) { + auth_details_extra.auto_admin = "" + !!reauthResult[2]; + auth_details_extra.require_signon = "" + !!reauthResult[3]; + } + if (!reauthResult[0]) { + throw new Components.Exception( + "User canceled OS reauth entry", + Cr.NS_ERROR_FAILURE, + null, + auth_details_extra + ); + } + let result = { + authenticated: true, + auth_details: "success", + auth_details_extra, + }; + if (reauthResult.length > 1 && reauthResult[1]) { + result.auth_details += "_no_password"; + } + return result; + }); + } else { + lazy.log.debug( + "ensureLoggedIn: Skipping reauth on unsupported platforms" + ); + unlockPromise = Promise.resolve({ + authenticated: true, + auth_details: "success_unsupported_platform", + }); + } + } else { + unlockPromise = Promise.resolve({ authenticated: true }); + } + + if (generateKeyIfNotAvailable) { + unlockPromise = unlockPromise.then(async reauthResult => { + if ( + !(await lazy.nativeOSKeyStore.asyncSecretAvailable(this.STORE_LABEL)) + ) { + lazy.log.debug( + "ensureLoggedIn: Secret unavailable, attempt to generate new secret." + ); + let recoveryPhrase = await lazy.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) + lazy.log.debug( + "ensureLoggedIn: Secret generated. Recovery phrase length: " + + recoveryPhrase.length + ); + } + return reauthResult; + }); + } + + unlockPromise = unlockPromise.then( + reauthResult => { + lazy.log.debug("ensureLoggedIn: Logged in"); + this._pendingUnlockPromise = null; + this._isLocked = false; + + return reauthResult; + }, + err => { + lazy.log.debug("ensureLoggedIn: Not logged in", err); + this._pendingUnlockPromise = null; + this._isLocked = true; + + return { + authenticated: false, + auth_details: "fail", + auth_details_extra: err.data?.QueryInterface(Ci.nsISupports) + .wrappedJSObject, + }; + } + ); + + 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 set to a string, prompt the reauth login dialog. + * The string may be shown on the native OS + * login dialog. Empty strings and `true` are disallowed. + * @returns {Promise<string>} resolves to the decrypted string, or rejects otherwise. + */ + async decrypt(cipherText, reauth = false) { + if (!(await this.ensureLoggedIn(reauth)).authenticated) { + throw Components.Exception( + "User canceled OS unlock entry", + Cr.NS_ERROR_ABORT + ); + } + let bytes = await lazy.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<string>} resolves to the encrypted string (with algorithm), otherwise rejects. + */ + async encrypt(plainText) { + if (!(await this.ensureLoggedIn()).authenticated) { + 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 lazy.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<boolean>} + * 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 lazy.nativeOSKeyStore.asyncDeleteSecret(this.STORE_LABEL); + }, +}; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + return new ConsoleAPI({ + maxLogLevelPref: "toolkit.osKeyStore.loglevel", + prefix: "OSKeyStore", + }); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + OSKeyStore, + "_testReauth", + TEST_ONLY_REAUTH, + "" +); |