Files
tubestation/toolkit/modules/OSKeyStore.sys.mjs
Stanca Serban 4f0eb0f875 Backed out 8 changesets (bug 1963014) for causing mochitests failures in browser_UsageTelemetry.js. CLOSED TREE
Backed out changeset 10cd387da114 (bug 1963014)
Backed out changeset db1cc23f2502 (bug 1963014)
Backed out changeset 076cbc895e0c (bug 1963014)
Backed out changeset 4df46947d96f (bug 1963014)
Backed out changeset 8692782e408c (bug 1963014)
Backed out changeset ddbecd248a02 (bug 1963014)
Backed out changeset f25d7077fec6 (bug 1963014)
Backed out changeset 96e088ca29d2 (bug 1963014)
2025-04-28 23:08:13 +03:00

385 lines
14 KiB
JavaScript

/* 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;
},
/**
* Exports the recovery phrase within the native OSKeyStore if authenticated
* as a byte string.
*
* @returns {Promise<string>}
*/
async exportRecoveryPhrase() {
if (!(await this.ensureLoggedIn()).authenticated) {
throw Components.Exception(
"User canceled OS unlock entry",
Cr.NS_ERROR_ABORT
);
}
return await lazy.nativeOSKeyStore.asyncGetRecoveryPhrase(this.STORE_LABEL);
},
/**
* 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,
""
);