Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / toolkit / modules / OSKeyStore.sys.mjs
blob3d759ed740b0820525a2b398111ace236879ebb3
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/. */
5 /**
6  * Helpers for using OS Key Store.
7  */
9 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
11 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
13 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
16 });
17 XPCOMUtils.defineLazyServiceGetter(
18   lazy,
19   "nativeOSKeyStore",
20   "@mozilla.org/security/oskeystore;1",
21   Ci.nsIOSKeyStore
23 XPCOMUtils.defineLazyServiceGetter(
24   lazy,
25   "osReauthenticator",
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 = {
34   /**
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.
42    */
43   STORE_LABEL: AppConstants.MOZ_APP_BASENAME + " Encrypted Storage",
45   /**
46    * Consider the module is initialized as locked. OS might unlock without a
47    * prompt.
48    * @type {Boolean}
49    */
50   _isLocked: true,
52   _pendingUnlockPromise: null,
54   /**
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.
59    */
60   get isLoggedIn() {
61     return !this._isLocked;
62   },
64   /**
65    * @returns {boolean} True if there is another login dialog existing and false
66    *                    otherwise.
67    */
68   get isUIBusy() {
69     return !!this._pendingUnlockPromise;
70   },
72   canReauth() {
73     // We have no support on linux (bug 1527745)
74     if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
75       lazy.log.debug(
76         "canReauth, returning true, this._testReauth:",
77         this._testReauth
78       );
79       return true;
80     }
81     lazy.log.debug("canReauth, returning false");
82     return false;
83   },
85   /**
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.
88    *
89    * @returns {Promise<undefined>} Resolves when sucessful login, rejects when
90    *                               login fails.
91    */
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) {
97       case "pass":
98         Services.obs.notifyObservers(
99           null,
100           "oskeystore-testonly-reauth",
101           "pass"
102         );
103         return { authenticated: true, auth_details: "success" };
104       case "cancel":
105         Services.obs.notifyObservers(
106           null,
107           "oskeystore-testonly-reauth",
108           "cancel"
109         );
110         throw new Components.Exception(
111           "Simulating user cancelling login dialog",
112           Cr.NS_ERROR_FAILURE
113         );
114       default:
115         throw new Components.Exception(
116           "Unknown test pref value",
117           Cr.NS_ERROR_FAILURE
118         );
119     }
120   },
122   /**
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.
126    *
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.
131    *
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
154    *                                  the mess later.
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.
158    */
159   async ensureLoggedIn(
160     reauth = false,
161     dialogCaption = "",
162     parentWindow = null,
163     generateKeyIfNotAvailable = true
164   ) {
165     if (
166       (typeof reauth != "boolean" && typeof reauth != "string") ||
167       reauth === true ||
168       reauth === ""
169     ) {
170       throw new Error(
171         "reauth is required to either be `false` or a non-empty string"
172       );
173     }
175     if (this._pendingUnlockPromise) {
176       lazy.log.debug("ensureLoggedIn: Has a pending unlock operation");
177       return this._pendingUnlockPromise;
178     }
179     lazy.log.debug(
180       "ensureLoggedIn: Creating new pending unlock promise. reauth: ",
181       reauth
182     );
184     let unlockPromise;
185     if (typeof reauth == "string") {
186       // Only allow for local builds
187       if (
188         lazy.UpdateUtils.getUpdateChannel(false) == "default" &&
189         this._testReauth
190       ) {
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];
202             }
203             if (!reauthResult[0]) {
204               throw new Components.Exception(
205                 "User canceled OS reauth entry",
206                 Cr.NS_ERROR_FAILURE,
207                 null,
208                 auth_details_extra
209               );
210             }
211             let result = {
212               authenticated: true,
213               auth_details: "success",
214               auth_details_extra,
215             };
216             if (reauthResult.length > 1 && reauthResult[1]) {
217               result.auth_details += "_no_password";
218             }
219             return result;
220           });
221       } else {
222         lazy.log.debug(
223           "ensureLoggedIn: Skipping reauth on unsupported platforms"
224         );
225         unlockPromise = Promise.resolve({
226           authenticated: true,
227           auth_details: "success_unsupported_platform",
228         });
229       }
230     } else {
231       unlockPromise = Promise.resolve({ authenticated: true });
232     }
234     if (generateKeyIfNotAvailable) {
235       unlockPromise = unlockPromise.then(async reauthResult => {
236         if (
237           !(await lazy.nativeOSKeyStore.asyncSecretAvailable(this.STORE_LABEL))
238         ) {
239           lazy.log.debug(
240             "ensureLoggedIn: Secret unavailable, attempt to generate new secret."
241           );
242           let recoveryPhrase = await lazy.nativeOSKeyStore.asyncGenerateSecret(
243             this.STORE_LABEL
244           );
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)
248           lazy.log.debug(
249             "ensureLoggedIn: Secret generated. Recovery phrase length: " +
250               recoveryPhrase.length
251           );
252         }
253         return reauthResult;
254       });
255     }
257     unlockPromise = unlockPromise.then(
258       reauthResult => {
259         lazy.log.debug("ensureLoggedIn: Logged in");
260         this._pendingUnlockPromise = null;
261         this._isLocked = false;
263         return reauthResult;
264       },
265       err => {
266         lazy.log.debug("ensureLoggedIn: Not logged in", err);
267         this._pendingUnlockPromise = null;
268         this._isLocked = true;
270         return {
271           authenticated: false,
272           auth_details: "fail",
273           auth_details_extra: err.data?.QueryInterface(Ci.nsISupports)
274             .wrappedJSObject,
275         };
276       }
277     );
279     this._pendingUnlockPromise = unlockPromise;
281     return this._pendingUnlockPromise;
282   },
284   /**
285    * Decrypts cipherText.
286    *
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.)
291    *
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.
297    */
298   async decrypt(cipherText, reauth = false) {
299     if (!(await this.ensureLoggedIn(reauth)).authenticated) {
300       throw Components.Exception(
301         "User canceled OS unlock entry",
302         Cr.NS_ERROR_ABORT
303       );
304     }
305     let bytes = await lazy.nativeOSKeyStore.asyncDecryptBytes(
306       this.STORE_LABEL,
307       cipherText
308     );
309     return String.fromCharCode.apply(String, bytes);
310   },
312   /**
313    * Encrypts a string and returns cipher text containing algorithm information used for decryption.
314    *
315    * @param   {string} plainText Original string without encryption.
316    * @returns {Promise<string>} resolves to the encrypted string (with algorithm), otherwise rejects.
317    */
318   async encrypt(plainText) {
319     if (!(await this.ensureLoggedIn()).authenticated) {
320       throw Components.Exception(
321         "User canceled OS unlock entry",
322         Cr.NS_ERROR_ABORT
323       );
324     }
326     // Convert plain text into a UTF-8 binary string
327     plainText = unescape(encodeURIComponent(plainText));
329     // Convert it to an array
330     let textArr = [];
331     for (let char of plainText) {
332       textArr.push(char.charCodeAt(0));
333     }
335     let rawEncryptedText = await lazy.nativeOSKeyStore.asyncEncryptBytes(
336       this.STORE_LABEL,
337       textArr
338     );
340     // Mark the output with a version number.
341     return rawEncryptedText;
342   },
344   /**
345    * Resolve when the login dialogs are closed, immediately if none are open.
346    *
347    * An existing MP dialog will be focused and will request attention.
348    *
349    * @returns {Promise<boolean>}
350    *          Resolves with whether the user is logged in to MP.
351    */
352   async waitForExistingDialog() {
353     if (this.isUIBusy) {
354       return this._pendingUnlockPromise;
355     }
356     return this.isLoggedIn;
357   },
359   /**
360    * Remove the store. For tests.
361    */
362   async cleanup() {
363     return lazy.nativeOSKeyStore.asyncDeleteSecret(this.STORE_LABEL);
364   },
367 ChromeUtils.defineLazyGetter(lazy, "log", () => {
368   let { ConsoleAPI } = ChromeUtils.importESModule(
369     "resource://gre/modules/Console.sys.mjs"
370   );
371   return new ConsoleAPI({
372     maxLogLevelPref: "toolkit.osKeyStore.loglevel",
373     prefix: "OSKeyStore",
374   });
377 XPCOMUtils.defineLazyPreferenceGetter(
378   OSKeyStore,
379   "_testReauth",
380   TEST_ONLY_REAUTH,
381   ""