зеркало из https://github.com/mozilla/gecko-dev.git
388 строки
14 KiB
JavaScript
388 строки
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() {
|
|
// The OS auth dialog is not supported on macOS < 10.12
|
|
// (Darwin 16) due to various issues (bug 1622304 and bug 1622303).
|
|
// We have no support on linux (bug 1527745.)
|
|
if (
|
|
AppConstants.platform == "win" ||
|
|
AppConstants.isPlatformAndVersionAtLeast("macosx", "16")
|
|
) {
|
|
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);
|
|
},
|
|
};
|
|
|
|
XPCOMUtils.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,
|
|
""
|
|
);
|