1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 * Helpers for using OS Key Store.
9 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
11 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
14 ChromeUtils.defineESModuleGetters(lazy, {
15 UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
17 XPCOMUtils.defineLazyServiceGetter(
20 "@mozilla.org/security/oskeystore;1",
23 XPCOMUtils.defineLazyServiceGetter(
26 "@mozilla.org/security/osreauthenticator;1",
27 Ci.nsIOSReauthenticator
30 // Skip reauth during tests, only works in non-official builds.
31 const TEST_ONLY_REAUTH = "toolkit.osKeyStore.unofficialBuildOnlyLogin";
33 export var OSKeyStore = {
35 * On macOS this becomes part of the name label visible on Keychain Acesss as
36 * "Firefox Encrypted Storage" (where "Firefox" is the MOZ_APP_BASENAME).
37 * Unfortunately, since this is the index into the keystore, we can't
38 * localize it without some really unfortunate side effects, like users
39 * losing access to stored information when they change their locale.
40 * This is a limitation of the interface exposed by macOS. Notably, both
41 * Chrome and Safari suffer the same shortcoming.
43 STORE_LABEL: AppConstants.MOZ_APP_BASENAME + " Encrypted Storage",
46 * Consider the module is initialized as locked. OS might unlock without a
52 _pendingUnlockPromise: null,
55 * @returns {boolean} True if logged in (i.e. decrypt(reauth = false) will
56 * not retrigger a dialog) and false if not.
57 * User might log out elsewhere in the OS, so even if this
58 * is true a prompt might still pop up.
61 return !this._isLocked;
65 * @returns {boolean} True if there is another login dialog existing and false
69 return !!this._pendingUnlockPromise;
73 // We have no support on linux (bug 1527745)
74 if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
76 "canReauth, returning true, this._testReauth:",
81 lazy.log.debug("canReauth, returning false");
86 * If the test pref exists, this method will dispatch a observer message and
87 * resolves to simulate successful reauth, or rejects to simulate failed reauth.
89 * @returns {Promise<undefined>} Resolves when sucessful login, rejects when
92 async _reauthInTests() {
93 // Skip this reauth because there is no way to mock the
94 // native dialog in the testing environment, for now.
95 lazy.log.debug("_reauthInTests: _testReauth: ", this._testReauth);
96 switch (this._testReauth) {
98 Services.obs.notifyObservers(
100 "oskeystore-testonly-reauth",
103 return { authenticated: true, auth_details: "success" };
105 Services.obs.notifyObservers(
107 "oskeystore-testonly-reauth",
110 throw new Components.Exception(
111 "Simulating user cancelling login dialog",
115 throw new Components.Exception(
116 "Unknown test pref value",
123 * Ensure the store in use is logged in. It will display the OS
124 * login prompt or do nothing if it's logged in already. If an existing login
125 * prompt is already prompted, the result from it will be used instead.
127 * Note: This method must set _pendingUnlockPromise before returning the
128 * promise (i.e. the first |await|), otherwise we'll risk re-entry.
129 * This is why there aren't an |await| in the method. The method is marked as
130 * |async| to communicate that it's async.
132 * @param {boolean|string} reauth If set to a string, prompt the reauth login dialog,
133 * showing the string on the native OS login dialog.
134 * Otherwise `false` will prevent showing the prompt.
135 * @param {string} dialogCaption The string will be shown on the native OS
136 * login dialog as the dialog caption (usually Product Name).
137 * @param {Window?} parentWindow The window of the caller, used to center the
138 * OS prompt in the middle of the application window.
139 * @param {boolean} generateKeyIfNotAvailable Makes key generation optional
140 * because it will currently cause more
141 * problems for us down the road on macOS since the application
142 * that creates the Keychain item is the only one that gets
143 * access to the key in the future and right now that key isn't
144 * specific to the channel or profile. This means if a user uses
145 * both DevEdition and Release on the same OS account (not
146 * unreasonable for a webdev.) then when you want to simply
147 * re-auth the user for viewing passwords you may also get a
148 * KeyChain prompt to allow the app to access the stored key even
149 * though that's not at all relevant for the re-auth. We skip the
150 * code here so that we can postpone deciding on how we want to
151 * handle this problem (multiple channels) until we actually use
152 * the key storage. If we start creating keys on macOS by running
153 * this code we'll potentially have to do extra work to cleanup
155 * @returns {Promise<Object>} Object with the following properties:
156 * authenticated: {boolean} Set to true if the user successfully authenticated.
157 * auth_details: {String?} Details of the authentication result.
159 async ensureLoggedIn(
163 generateKeyIfNotAvailable = true
166 (typeof reauth != "boolean" && typeof reauth != "string") ||
171 "reauth is required to either be `false` or a non-empty string"
175 if (this._pendingUnlockPromise) {
176 lazy.log.debug("ensureLoggedIn: Has a pending unlock operation");
177 return this._pendingUnlockPromise;
180 "ensureLoggedIn: Creating new pending unlock promise. reauth: ",
185 if (typeof reauth == "string") {
186 // Only allow for local builds
188 lazy.UpdateUtils.getUpdateChannel(false) == "default" &&
191 unlockPromise = this._reauthInTests();
192 } else if (this.canReauth()) {
193 // On Windows, this promise rejects when the user cancels login dialog, see bug 1502121.
194 // On macOS this resolves to false, so we would need to check it.
195 unlockPromise = lazy.osReauthenticator
196 .asyncReauthenticateUser(reauth, dialogCaption, parentWindow)
197 .then(reauthResult => {
198 let auth_details_extra = {};
199 if (reauthResult.length > 3) {
200 auth_details_extra.auto_admin = "" + !!reauthResult[2];
201 auth_details_extra.require_signon = "" + !!reauthResult[3];
203 if (!reauthResult[0]) {
204 throw new Components.Exception(
205 "User canceled OS reauth entry",
213 auth_details: "success",
216 if (reauthResult.length > 1 && reauthResult[1]) {
217 result.auth_details += "_no_password";
223 "ensureLoggedIn: Skipping reauth on unsupported platforms"
225 unlockPromise = Promise.resolve({
227 auth_details: "success_unsupported_platform",
231 unlockPromise = Promise.resolve({ authenticated: true });
234 if (generateKeyIfNotAvailable) {
235 unlockPromise = unlockPromise.then(async reauthResult => {
237 !(await lazy.nativeOSKeyStore.asyncSecretAvailable(this.STORE_LABEL))
240 "ensureLoggedIn: Secret unavailable, attempt to generate new secret."
242 let recoveryPhrase = await lazy.nativeOSKeyStore.asyncGenerateSecret(
245 // TODO We should somehow have a dialog to ask the user to write this down,
246 // and another dialog somewhere for the user to restore the secret with it.
247 // (Intentionally not printing it out in the console)
249 "ensureLoggedIn: Secret generated. Recovery phrase length: " +
250 recoveryPhrase.length
257 unlockPromise = unlockPromise.then(
259 lazy.log.debug("ensureLoggedIn: Logged in");
260 this._pendingUnlockPromise = null;
261 this._isLocked = false;
266 lazy.log.debug("ensureLoggedIn: Not logged in", err);
267 this._pendingUnlockPromise = null;
268 this._isLocked = true;
271 authenticated: false,
272 auth_details: "fail",
273 auth_details_extra: err.data?.QueryInterface(Ci.nsISupports)
279 this._pendingUnlockPromise = unlockPromise;
281 return this._pendingUnlockPromise;
285 * Decrypts cipherText.
287 * Note: In the event of an rejection, check the result property of the Exception
288 * object. Handles NS_ERROR_ABORT as user has cancelled the action (e.g.,
289 * don't show that dialog), apart from other errors (e.g., gracefully
290 * recover from that and still shows the dialog.)
292 * @param {string} cipherText Encrypted string including the algorithm details.
293 * @param {boolean|string} reauth If set to a string, prompt the reauth login dialog.
294 * The string may be shown on the native OS
295 * login dialog. Empty strings and `true` are disallowed.
296 * @returns {Promise<string>} resolves to the decrypted string, or rejects otherwise.
298 async decrypt(cipherText, reauth = false) {
299 if (!(await this.ensureLoggedIn(reauth)).authenticated) {
300 throw Components.Exception(
301 "User canceled OS unlock entry",
305 let bytes = await lazy.nativeOSKeyStore.asyncDecryptBytes(
309 return String.fromCharCode.apply(String, bytes);
313 * Encrypts a string and returns cipher text containing algorithm information used for decryption.
315 * @param {string} plainText Original string without encryption.
316 * @returns {Promise<string>} resolves to the encrypted string (with algorithm), otherwise rejects.
318 async encrypt(plainText) {
319 if (!(await this.ensureLoggedIn()).authenticated) {
320 throw Components.Exception(
321 "User canceled OS unlock entry",
326 // Convert plain text into a UTF-8 binary string
327 plainText = unescape(encodeURIComponent(plainText));
329 // Convert it to an array
331 for (let char of plainText) {
332 textArr.push(char.charCodeAt(0));
335 let rawEncryptedText = await lazy.nativeOSKeyStore.asyncEncryptBytes(
340 // Mark the output with a version number.
341 return rawEncryptedText;
345 * Resolve when the login dialogs are closed, immediately if none are open.
347 * An existing MP dialog will be focused and will request attention.
349 * @returns {Promise<boolean>}
350 * Resolves with whether the user is logged in to MP.
352 async waitForExistingDialog() {
354 return this._pendingUnlockPromise;
356 return this.isLoggedIn;
360 * Remove the store. For tests.
363 return lazy.nativeOSKeyStore.asyncDeleteSecret(this.STORE_LABEL);
367 ChromeUtils.defineLazyGetter(lazy, "log", () => {
368 let { ConsoleAPI } = ChromeUtils.importESModule(
369 "resource://gre/modules/Console.sys.mjs"
371 return new ConsoleAPI({
372 maxLogLevelPref: "toolkit.osKeyStore.loglevel",
373 prefix: "OSKeyStore",
377 XPCOMUtils.defineLazyPreferenceGetter(