348 lines
11 KiB
JavaScript
348 lines
11 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/. */
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
|
|
return console.createInstance({
|
|
prefix: "BackupService::ArchiveEncryption",
|
|
maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false)
|
|
? "Debug"
|
|
: "Warn",
|
|
});
|
|
});
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs",
|
|
OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
|
|
BackupError: "resource:///modules/backup/BackupError.mjs",
|
|
ERRORS: "chrome://browser/content/backup/backup-constants.mjs",
|
|
});
|
|
|
|
/**
|
|
* ArchiveEncryptionState encapsulates key primitives and wrapped secrets that
|
|
* can be safely serialized to the filesystem. An ArchiveEncryptionState is
|
|
* used to compute the necessary keys for encrypting a backup archive.
|
|
*/
|
|
export class ArchiveEncryptionState {
|
|
/**
|
|
* A hack that lets us ensure that an ArchiveEncryptionState cannot be
|
|
* constructed except via the ArchiveEncryptionState.initialize static
|
|
* method.
|
|
*
|
|
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
|
|
*/
|
|
static #isInternalConstructing = false;
|
|
|
|
/**
|
|
* A reference to an object holding the current state of the
|
|
* ArchiveEncryptionState instance. When this reference is null, encryption
|
|
* is not considered enabled.
|
|
*/
|
|
#state = null;
|
|
|
|
/**
|
|
* The current version number of the ArchiveEncryptionState. This is encoded
|
|
* in the serialized state, and is also used during calculation of the salt
|
|
* in enable().
|
|
*
|
|
* @type {number}
|
|
*/
|
|
static get VERSION() {
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* The number of characters to generate with a CSRNG (crypto.getRandomValues)
|
|
* if no recovery code is passed in to enable();
|
|
*
|
|
* @type {number}
|
|
*/
|
|
static get GENERATED_RECOVERY_CODE_LENGTH() {
|
|
return 14;
|
|
}
|
|
|
|
/**
|
|
* The RSA-OAEP public key that will be used to derive keys for encrypting
|
|
* backups.
|
|
*
|
|
* @type {CryptoKey}
|
|
*/
|
|
get publicKey() {
|
|
return this.#state.publicKey;
|
|
}
|
|
|
|
/**
|
|
* The AES-GCM key that will be used to authenticate the owner of the backup.
|
|
*
|
|
* @type {CryptoKey}
|
|
*/
|
|
get backupAuthKey() {
|
|
return this.#state.backupAuthKey;
|
|
}
|
|
|
|
/**
|
|
* A salt computed for the PBKDF2 stretching of the recovery code.
|
|
*
|
|
* @type {Uint8Array}
|
|
*/
|
|
get salt() {
|
|
return this.#state.salt;
|
|
}
|
|
|
|
/**
|
|
* A nonce computed when wrapping the private key and OSKeyStore secret.
|
|
*
|
|
* @type {Uint8Array}
|
|
*/
|
|
get nonce() {
|
|
return this.#state.nonce;
|
|
}
|
|
|
|
/**
|
|
* The wrapped static secrets, including the RSA-OAEP private key, and the
|
|
* OSKeyStore secret.
|
|
*
|
|
* @type {Uint8Array}
|
|
*/
|
|
get wrappedSecrets() {
|
|
return this.#state.wrappedSecrets;
|
|
}
|
|
|
|
constructor() {
|
|
if (!ArchiveEncryptionState.#isInternalConstructing) {
|
|
throw new lazy.BackupError(
|
|
"ArchiveEncryptionState is not constructable.",
|
|
lazy.ERRORS.UNKNOWN
|
|
);
|
|
}
|
|
ArchiveEncryptionState.#isInternalConstructing = false;
|
|
}
|
|
|
|
/**
|
|
* Calculates various encryption keys and other information necessary to
|
|
* encrypt backups, based on the passed in recoveryCode.
|
|
*
|
|
* This will throw if encryption is already enabled for this
|
|
* ArchiveEncryptionState.
|
|
*
|
|
* @throws {Exception}
|
|
* @param {string} [recoveryCode=null]
|
|
* A recovery code that will be used to drive the various encryption keys
|
|
* and data for backup encryption. If not supplied by the caller, a
|
|
* recovery code will be generated.
|
|
* @returns {Promise<string>}
|
|
* Resolves with the recovery code string. If callers did not pass the
|
|
* recovery code in as an argument, they should not store it. They should
|
|
* instead display this string to the user, and then forget it altogether.
|
|
*/
|
|
async #enable(recoveryCode = null) {
|
|
lazy.logConsole.debug("Creating new enabled ArchiveEncryptionState");
|
|
|
|
lazy.logConsole.debug("Generating an RSA-OEAP keyPair");
|
|
let keyPair = await crypto.subtle.generateKey(
|
|
{
|
|
name: "RSA-OAEP",
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: { name: "SHA-256" },
|
|
},
|
|
true /* extractable */,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
|
|
if (!recoveryCode) {
|
|
// A recovery code wasn't provided, so we'll generate one using
|
|
// getRandomValues, and make sure it's GENERATED_RECOVERY_CODE_LENGTH
|
|
// characters long.
|
|
recoveryCode = "";
|
|
// We've intentionally replaced some lookalike characters (O, o, 0, l, I,
|
|
// 1) with symbols.
|
|
const charset =
|
|
"ABCDEFGH#JKLMN@PQRSTUVWXYZabcdefgh=jklmn+pqrstuvwxyz%!23456789";
|
|
// getRandomValues will return a value between 0-255. In order to not
|
|
// gain a bias on any particular character (due to wrap-around), we'll
|
|
// ensure that we only consider random values that are less than or
|
|
// equal to the highest multiple of charset.length that is less than
|
|
// 255.
|
|
let highestMultiple =
|
|
Math.floor((255 /* upper limit */ - 1) / charset.length) *
|
|
charset.length;
|
|
|
|
while (
|
|
recoveryCode.length <
|
|
ArchiveEncryptionState.GENERATED_RECOVERY_CODE_LENGTH
|
|
) {
|
|
let randomValue = new Uint8Array(1);
|
|
crypto.getRandomValues(randomValue);
|
|
// If the random value is higher than highestMultiple, try again.
|
|
if (randomValue > highestMultiple) {
|
|
continue;
|
|
}
|
|
// Otherwise, we're within the highest multiple, meaning we can mod
|
|
// the generated number to choose a character from charset.
|
|
let randomIndex = randomValue % charset.length;
|
|
recoveryCode += charset[randomIndex];
|
|
}
|
|
}
|
|
|
|
// Next, we generate a 32-byte salt, and then concatenate a static suffix
|
|
// to it, including the version number.
|
|
lazy.logConsole.debug("Creating salt");
|
|
let textEncoder = new TextEncoder();
|
|
const SALT_SUFFIX = textEncoder.encode(
|
|
"backupkey-v" + ArchiveEncryptionState.VERSION
|
|
);
|
|
let saltPrefix = new Uint8Array(32);
|
|
crypto.getRandomValues(saltPrefix);
|
|
|
|
let salt = new Uint8Array(saltPrefix.length + SALT_SUFFIX.length);
|
|
salt.set(saltPrefix);
|
|
salt.set(SALT_SUFFIX, saltPrefix.length);
|
|
|
|
let { backupAuthKey, backupEncKey } =
|
|
await lazy.ArchiveUtils.computeBackupKeys(recoveryCode, salt);
|
|
|
|
lazy.logConsole.debug("Encrypting secrets with encKey");
|
|
const NONCE_SIZE = 96;
|
|
let nonce = crypto.getRandomValues(new Uint8Array(NONCE_SIZE));
|
|
|
|
let secrets = JSON.stringify({
|
|
privateKey: await crypto.subtle.exportKey("jwk", keyPair.privateKey),
|
|
OSKeyStoreSecret: await lazy.OSKeyStore.exportRecoveryPhrase(),
|
|
});
|
|
let secretsBytes = textEncoder.encode(secrets);
|
|
|
|
let wrappedSecrets = new Uint8Array(
|
|
await crypto.subtle.encrypt(
|
|
{
|
|
name: "AES-GCM",
|
|
iv: nonce,
|
|
},
|
|
backupEncKey,
|
|
secretsBytes
|
|
)
|
|
);
|
|
|
|
this.#state = {
|
|
publicKey: keyPair.publicKey,
|
|
salt,
|
|
backupAuthKey,
|
|
nonce,
|
|
wrappedSecrets,
|
|
};
|
|
|
|
return recoveryCode;
|
|
}
|
|
|
|
/**
|
|
* Serializes an ArchiveEncryptionState instance into an object that can be
|
|
* safely persisted to disk.
|
|
*
|
|
* @returns {Promise<object>}
|
|
*/
|
|
async serialize() {
|
|
let publicKey = await crypto.subtle.exportKey("jwk", this.#state.publicKey);
|
|
let salt = lazy.ArchiveUtils.arrayToBase64(this.#state.salt);
|
|
let backupAuthKey = lazy.ArchiveUtils.arrayToBase64(
|
|
this.#state.backupAuthKey
|
|
);
|
|
let nonce = lazy.ArchiveUtils.arrayToBase64(this.#state.nonce);
|
|
let wrappedSecrets = lazy.ArchiveUtils.arrayToBase64(
|
|
this.#state.wrappedSecrets
|
|
);
|
|
let result = {
|
|
publicKey,
|
|
salt,
|
|
backupAuthKey,
|
|
nonce,
|
|
wrappedSecrets,
|
|
version: ArchiveEncryptionState.VERSION,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Deserializes an object created via serialize() and updates its internal
|
|
* state to match the deserialization.
|
|
*
|
|
* @param {object} stateData
|
|
* The object generated via serialize()
|
|
* @returns {Promise<undefined>}
|
|
*/
|
|
async #deserialize(stateData) {
|
|
lazy.logConsole.debug(
|
|
"Deserializing from state with version ",
|
|
stateData.version
|
|
);
|
|
|
|
// If we ever need to do a migration from one ArchiveEncryptionState
|
|
// version to another, this is where we might do it. We don't currently
|
|
// have any need to do migrations just yet though, so any version that
|
|
// doesn't match the one that we can accept is rejected.
|
|
if (stateData.version != ArchiveEncryptionState.VERSION) {
|
|
throw new lazy.BackupError(
|
|
"The ArchiveEncryptionState version is from a newer version.",
|
|
lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION
|
|
);
|
|
}
|
|
|
|
let publicKey = await crypto.subtle.importKey(
|
|
"jwk",
|
|
stateData.publicKey,
|
|
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
true /* extractable */,
|
|
["encrypt"]
|
|
);
|
|
let backupAuthKey = lazy.ArchiveUtils.stringToArray(
|
|
stateData.backupAuthKey
|
|
);
|
|
let salt = lazy.ArchiveUtils.stringToArray(stateData.salt);
|
|
let nonce = lazy.ArchiveUtils.stringToArray(stateData.nonce);
|
|
let wrappedSecrets = lazy.ArchiveUtils.stringToArray(
|
|
stateData.wrappedSecrets
|
|
);
|
|
|
|
this.#state = {
|
|
publicKey,
|
|
backupAuthKey,
|
|
salt,
|
|
nonce,
|
|
wrappedSecrets,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} InitializationResult
|
|
* @property {string|undefined} recoveryCode
|
|
* The generated recovery code if the initialization happened without
|
|
* deserialization.
|
|
* @property {ArchiveEncryptionState} instance
|
|
* The constructed ArchiveEncryptionState.
|
|
*/
|
|
|
|
/**
|
|
* Constructs a new ArchiveEncryptionState. If a stateData object is passed,
|
|
* the ArchiveEncryptionState will attempt to be deserialized from it -
|
|
* otherwise, new state data will be generated automatically. This might
|
|
* reject if the user is prompted to authenticate to their OSKeyStore, and
|
|
* they cancel the authentication.
|
|
*
|
|
* @param {object|string|undefined} stateDataOrRecoveryCode
|
|
* Either the object generated via serialize(), a recovery code to be
|
|
* used to generate the state, or undefined.
|
|
* @returns {Promise<InitializationResult>}
|
|
*/
|
|
static async initialize(stateDataOrRecoveryCode) {
|
|
ArchiveEncryptionState.#isInternalConstructing = true;
|
|
let instance = new ArchiveEncryptionState();
|
|
if (typeof stateDataOrRecoveryCode == "object") {
|
|
await instance.#deserialize(stateDataOrRecoveryCode);
|
|
return { instance };
|
|
}
|
|
let recoveryCode = await instance.#enable(stateDataOrRecoveryCode);
|
|
return { instance, recoveryCode };
|
|
}
|
|
}
|