зеркало из https://github.com/mozilla/gecko-dev.git
313 строки
9.7 KiB
JavaScript
313 строки
9.7 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.
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
var EXPORTED_SYMBOLS = ["OSKeyStore"];
|
|
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"AppConstants",
|
|
"resource://gre/modules/AppConstants.jsm"
|
|
);
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"nativeOSKeyStore",
|
|
"@mozilla.org/security/oskeystore;1",
|
|
Ci.nsIOSKeyStore
|
|
);
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
this,
|
|
"osReauthenticator",
|
|
"@mozilla.org/security/osreauthenticator;1",
|
|
Ci.nsIOSReauthenticator
|
|
);
|
|
|
|
// Skip reauth during tests, only works in non-official builds.
|
|
const TEST_ONLY_REAUTH =
|
|
"extensions.formautofill.osKeyStore.unofficialBuildOnlyLogin";
|
|
|
|
var OSKeyStore = {
|
|
/**
|
|
* On macOS this becomes part of the name label visible on Keychain Acesss as
|
|
* "org.mozilla.nss.keystore.firefox" (where "firefox" is the MOZ_APP_NAME).
|
|
*/
|
|
STORE_LABEL: AppConstants.MOZ_APP_NAME,
|
|
|
|
/**
|
|
* 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;
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
log.debug("_ensureReauth: _testReauth: ", this._testReauth);
|
|
switch (this._testReauth) {
|
|
case "pass":
|
|
Services.obs.notifyObservers(
|
|
null,
|
|
"oskeystore-testonly-reauth",
|
|
"pass"
|
|
);
|
|
break;
|
|
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
|
|
* 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 it's set to true or a string, prompt
|
|
* the reauth login dialog.
|
|
* The string will be shown on the native OS
|
|
* login dialog.
|
|
* @returns {Promise<boolean>} True if it's logged in or no password is set
|
|
* and false if it's still not logged in (prompt
|
|
* canceled or other error).
|
|
*/
|
|
async ensureLoggedIn(reauth = false) {
|
|
if (this._pendingUnlockPromise) {
|
|
log.debug("ensureLoggedIn: Has a pending unlock operation");
|
|
return this._pendingUnlockPromise;
|
|
}
|
|
log.debug(
|
|
"ensureLoggedIn: Creating new pending unlock promise. reauth: ",
|
|
reauth
|
|
);
|
|
|
|
let unlockPromise;
|
|
|
|
// Decides who should handle reauth
|
|
if (!this._reauthEnabledByUser || (typeof reauth == "boolean" && !reauth)) {
|
|
unlockPromise = Promise.resolve();
|
|
} else if (!AppConstants.MOZILLA_OFFICIAL && this._testReauth) {
|
|
unlockPromise = this._reauthInTests();
|
|
} else if (
|
|
AppConstants.platform == "win" ||
|
|
AppConstants.platform == "macosx"
|
|
) {
|
|
let reauthLabel = typeof reauth == "string" ? reauth : "";
|
|
// 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 = osReauthenticator
|
|
.asyncReauthenticateUser(reauthLabel)
|
|
.then(reauthResult => {
|
|
if (typeof reauthResult == "boolean" && !reauthResult) {
|
|
throw new Components.Exception(
|
|
"User canceled OS reauth entry",
|
|
Cr.NS_ERROR_FAILURE
|
|
);
|
|
}
|
|
});
|
|
} else {
|
|
log.debug("ensureLoggedIn: Skipping reauth on unsupported platforms");
|
|
unlockPromise = Promise.resolve();
|
|
}
|
|
|
|
unlockPromise = unlockPromise.then(async () => {
|
|
if (!(await nativeOSKeyStore.asyncSecretAvailable(this.STORE_LABEL))) {
|
|
log.debug(
|
|
"ensureLoggedIn: Secret unavailable, attempt to generate new secret."
|
|
);
|
|
let recoveryPhrase = await 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)
|
|
log.debug(
|
|
"ensureLoggedIn: Secret generated. Recovery phrase length: " +
|
|
recoveryPhrase.length
|
|
);
|
|
}
|
|
});
|
|
|
|
unlockPromise = unlockPromise.then(
|
|
() => {
|
|
log.debug("ensureLoggedIn: Logged in");
|
|
this._pendingUnlockPromise = null;
|
|
this._isLocked = false;
|
|
|
|
return true;
|
|
},
|
|
err => {
|
|
log.debug("ensureLoggedIn: Not logged in", err);
|
|
this._pendingUnlockPromise = null;
|
|
this._isLocked = true;
|
|
|
|
return false;
|
|
}
|
|
);
|
|
|
|
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 it's set to true or a string, prompt
|
|
* the reauth login dialog.
|
|
* The string may be shown on the native OS
|
|
* login dialog.
|
|
* @returns {Promise<string>} resolves to the decrypted string, or rejects otherwise.
|
|
*/
|
|
async decrypt(cipherText, reauth = false) {
|
|
if (!(await this.ensureLoggedIn(reauth))) {
|
|
throw Components.Exception(
|
|
"User canceled OS unlock entry",
|
|
Cr.NS_ERROR_ABORT
|
|
);
|
|
}
|
|
let bytes = await 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())) {
|
|
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 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 nativeOSKeyStore.asyncDeleteSecret(this.STORE_LABEL);
|
|
},
|
|
};
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "log", () => {
|
|
let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {})
|
|
.ConsoleAPI;
|
|
return new ConsoleAPI({
|
|
maxLogLevelPref: "extensions.formautofill.loglevel",
|
|
prefix: "OSKeyStore",
|
|
});
|
|
});
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
OSKeyStore,
|
|
"_testReauth",
|
|
TEST_ONLY_REAUTH,
|
|
""
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
OSKeyStore,
|
|
"_reauthEnabledByUser",
|
|
"extensions.formautofill.reauth.enabled",
|
|
false
|
|
);
|