Bug 1897278 - Add methods to BackupService to enable / disable encryption. r=backup-reviewers,kpatenio

Differential Revision: https://phabricator.services.mozilla.com/D211438
This commit is contained in:
Mike Conley
2024-06-05 13:49:29 +00:00
parent 361e271c49
commit 8348a05e60
8 changed files with 584 additions and 12 deletions

View File

@@ -25,6 +25,8 @@ ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
});
ChromeUtils.defineESModuleGetters(lazy, {
ArchiveEncryptionState:
"resource:///modules/backup/ArchiveEncryptionState.sys.mjs",
ClientID: "resource://gre/modules/ClientID.sys.mjs",
JsonSchemaValidator:
"resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
@@ -113,6 +115,7 @@ export class BackupService extends EventTarget {
backupFilePath: "Documents", // TODO: make save location configurable (bug 1895943)
backupInProgress: false,
scheduledBackupsEnabled: lazy.scheduledBackupsPref,
encryptionEnabled: false,
};
/**
@@ -132,6 +135,23 @@ export class BackupService extends EventTarget {
*/
#postRecoveryResolver;
/**
* The currently used ArchiveEncryptionState. Callers should use
* loadEncryptionState() instead, to ensure that any pre-serialized
* encryption state has been read in and deserialized.
*
* This member can be in 3 states:
*
* 1. undefined - no attempt has been made to load encryption state from
* disk yet.
* 2. null - encryption is not enabled.
* 3. ArchiveEncryptionState - encryption is enabled.
*
* @see BackupService.loadEncryptionState();
* @type {ArchiveEncryptionState|null|undefined}
*/
#encState = undefined;
/**
* The name of the folder within the profile folder where this service reads
* and writes state to.
@@ -196,6 +216,16 @@ export class BackupService extends EventTarget {
return "post-recovery.json";
}
/**
* The name of the serialized ArchiveEncryptionState that is written to disk
* if encryption is enabled.
*
* @type {string}
*/
static get ARCHIVE_ENCRYPTION_STATE_FILE() {
return "enc-state.json";
}
/**
* Returns the schema for the backup manifest for a given version.
*
@@ -264,7 +294,8 @@ export class BackupService extends EventTarget {
/**
* Create a BackupService instance.
*
* @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service.
* @param {object} [backupResources=DefaultBackupResources]
* Object containing BackupResource classes to associate with this service.
*/
constructor(backupResources = DefaultBackupResources) {
super();
@@ -355,6 +386,10 @@ export class BackupService extends EventTarget {
}
);
let encState = await this.loadEncryptionState(profilePath);
let encryptionEnabled = !!encState;
lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
// Perform the backup for each resource.
for (let resourceClass of sortedResources) {
try {
@@ -362,6 +397,14 @@ export class BackupService extends EventTarget {
`Backing up resource with key ${resourceClass.key}. ` +
`Requires encryption: ${resourceClass.requiresEncryption}`
);
if (resourceClass.requiresEncryption && !encryptionEnabled) {
lazy.logConsole.debug(
"Encryption is not currently enabled. Skipping."
);
continue;
}
let resourcePath = PathUtils.join(stagingPath, resourceClass.key);
await IOUtils.makeDirectory(resourcePath);
@@ -584,10 +627,15 @@ export class BackupService extends EventTarget {
/**
* Bug 1892532: for now, we only support a single backup file.
* If there are other pre-existing backup folders, delete them.
* If there are other pre-existing backup folders, delete them - but don't
* delete anything that doesn't match the backup folder naming scheme.
*/
let expectedFormatRegex = /\d{4}(-\d{2}){2}T(\d{2}-){2}\d{2}Z/;
for (let existingBackupPath of existingBackups) {
if (existingBackupPath !== renamedBackupPath) {
if (
existingBackupPath !== renamedBackupPath &&
existingBackupPath.match(expectedFormatRegex)
) {
await IOUtils.remove(existingBackupPath, {
recursive: true,
});
@@ -944,4 +992,153 @@ export class BackupService extends EventTarget {
}
}
}
/**
* The internal promise that is created on the first call to
* loadEncryptionState.
*
* @type {Promise}
*/
#loadEncryptionStatePromise = null;
/**
* Returns the current ArchiveEncryptionState. This method will only attempt
* to read the state from the disk the first time it is called.
*
* @param {string} [profilePath=PathUtils.profileDir]
* The profile path where the encryption state might exist. This is only
* used for testing.
* @returns {Promise<ArchiveEncryptionState>}
*/
loadEncryptionState(profilePath = PathUtils.profileDir) {
if (this.#encState !== undefined) {
return Promise.resolve(this.#encState);
}
// This little dance makes it so that we only attempt to read the state off
// of the disk the first time `loadEncryptionState` is called. Any
// subsequent calls will await this same promise, OR, after the state has
// been read in, they'll just get the #encState which is set after the
// state has been read in.
if (!this.#loadEncryptionStatePromise) {
this.#loadEncryptionStatePromise = (async () => {
// Default this to null here - that way, if we fail to read it in,
// the null will indicate that we have at least _tried_ to load the
// state.
let encState = null;
let encStateFile = PathUtils.join(
profilePath,
BackupService.PROFILE_FOLDER_NAME,
BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
);
// Try to read in any pre-existing encryption state. If that fails,
// we fallback to not encrypting, and only backing up non-sensitive data.
try {
if (await IOUtils.exists(encStateFile)) {
let stateObject = await IOUtils.readJSON(encStateFile);
({ instance: encState } =
await lazy.ArchiveEncryptionState.initialize(stateObject));
}
} catch (e) {
lazy.logConsole.error(
"Failed to read / deserialize archive encryption state file: ",
e
);
// TODO: This kind of error might be worth collecting telemetry on.
}
this.#_state.encryptionEnabled = !!encState;
this.stateUpdate();
this.#encState = encState;
return encState;
})();
}
return this.#loadEncryptionStatePromise;
}
/**
* Enables encryption for backups, allowing sensitive data to be backed up.
* Throws if encryption is already enabled. After enabling encryption, that
* state is written to disk.
*
* @throws Exception
* @param {string} password
* A non-blank password ("recovery code") that can be used to derive keys
* for encrypting the backup.
* @param {string} [profilePath=PathUtils.profileDir]
* The profile path where the encryption state will be written. This is only
* used for testing.
*/
async enableEncryption(password, profilePath = PathUtils.profileDir) {
lazy.logConsole.debug("Enabling encryption.");
let encState = await this.loadEncryptionState(profilePath);
if (encState) {
throw new Error("Encryption is already enabled.");
}
if (!password) {
throw new Error("Cannot supply a blank password.");
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
// TODO: Enforce other password rules here, such as ensuring that the
// password is not considered common.
({ instance: encState } = await lazy.ArchiveEncryptionState.initialize(
password
));
if (!encState) {
throw new Error("Failed to construct ArchiveEncryptionState");
}
this.#encState = encState;
let encStateFile = PathUtils.join(
profilePath,
BackupService.PROFILE_FOLDER_NAME,
BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
);
let stateObj = await encState.serialize();
await IOUtils.writeJSON(encStateFile, stateObj);
this.#_state.encryptionEnabled = true;
this.stateUpdate();
}
/**
* Disables encryption of backups. Throws is encryption is already disabled.
*
* @throws Exception
* @param {string} [profilePath=PathUtils.profileDir]
* The profile path where the encryption state exists. This is only used for
* testing.
* @returns {Promise<undefined>}
*/
async disableEncryption(profilePath = PathUtils.profileDir) {
lazy.logConsole.debug("Disabling encryption.");
let encState = await this.loadEncryptionState(profilePath);
if (!encState) {
throw new Error("Encryption is already disabled.");
}
let encStateFile = PathUtils.join(
profilePath,
BackupService.PROFILE_FOLDER_NAME,
BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
);
// It'd be pretty strange, but not impossible, for something else to have
// gotten rid of the encryption state file at this point. We'll ignore it
// if that's the case.
await IOUtils.remove(encStateFile, { ignoreAbsent: true });
this.#encState = null;
this.#_state.encryptionEnabled = false;
this.stateUpdate();
}
}