1140 lines
33 KiB
TypeScript
1140 lines
33 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { pipeline } from 'stream/promises';
|
|
import { PassThrough } from 'stream';
|
|
import type { Readable, Writable } from 'stream';
|
|
import { createReadStream, createWriteStream } from 'fs';
|
|
import { mkdir, stat, unlink } from 'fs/promises';
|
|
import { ensureFile } from 'fs-extra';
|
|
import { join } from 'path';
|
|
import { createGzip, createGunzip } from 'zlib';
|
|
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
|
import { isEqual, noop } from 'lodash';
|
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
|
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
|
|
import { throttle } from 'lodash/fp';
|
|
import { ipcRenderer } from 'electron';
|
|
|
|
import { DataReader, DataWriter } from '../../sql/Client';
|
|
import { createLogger } from '../../logging/log';
|
|
import * as Bytes from '../../Bytes';
|
|
import { strictAssert } from '../../util/assert';
|
|
import { drop } from '../../util/drop';
|
|
import { DelimitedStream } from '../../util/DelimitedStream';
|
|
import { appendPaddingStream } from '../../util/logPadding';
|
|
import { prependStream } from '../../util/prependStream';
|
|
import { appendMacStream } from '../../util/appendMacStream';
|
|
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
|
|
import { missingCaseError } from '../../util/missingCaseError';
|
|
import { DAY, HOUR, SECOND } from '../../util/durations';
|
|
import type { ExplodePromiseResultType } from '../../util/explodePromise';
|
|
import { explodePromise } from '../../util/explodePromise';
|
|
import type { RetryBackupImportValue } from '../../state/ducks/installer';
|
|
import { CipherType, HashType } from '../../types/Crypto';
|
|
import {
|
|
InstallScreenBackupStep,
|
|
InstallScreenBackupError,
|
|
} from '../../types/InstallScreen';
|
|
import * as Errors from '../../types/errors';
|
|
import {
|
|
BackupCredentialType,
|
|
type BackupsSubscriptionType,
|
|
type BackupStatusType,
|
|
} from '../../types/backups';
|
|
import { HTTPError } from '../../textsecure/Errors';
|
|
import { constantTimeEqual } from '../../Crypto';
|
|
import { measureSize } from '../../AttachmentCrypto';
|
|
import { isTestOrMockEnvironment } from '../../environment';
|
|
import { runStorageServiceSyncJob } from '../storage';
|
|
import { BackupExportStream, type StatsType } from './export';
|
|
import { BackupImportStream } from './import';
|
|
import {
|
|
getBackupId,
|
|
getKeyMaterial,
|
|
getLocalBackupMetadataKey,
|
|
} from './crypto';
|
|
import { BackupCredentials } from './credentials';
|
|
import { BackupAPI } from './api';
|
|
import {
|
|
validateBackup,
|
|
validateBackupStream,
|
|
ValidationType,
|
|
} from './validator';
|
|
import { BackupType } from './types';
|
|
import {
|
|
BackupInstallerError,
|
|
BackupDownloadFailedError,
|
|
BackupImportCanceledError,
|
|
BackupProcessingError,
|
|
RelinkRequestedError,
|
|
} from './errors';
|
|
import { FileStream } from './util/FileStream';
|
|
import { ToastType } from '../../types/Toast';
|
|
import { isAdhoc, isNightly } from '../../util/version';
|
|
import { getMessageQueueTime } from '../../util/getMessageQueueTime';
|
|
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled';
|
|
import type { ValidateLocalBackupStructureResultType } from './util/localBackup';
|
|
import {
|
|
writeLocalBackupMetadata,
|
|
verifyLocalBackupMetadata,
|
|
writeLocalBackupFilesList,
|
|
readLocalBackupFilesList,
|
|
validateLocalBackupStructure,
|
|
} from './util/localBackup';
|
|
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager';
|
|
import { decipherWithAesKey } from '../../util/decipherWithAesKey';
|
|
|
|
const log = createLogger('index');
|
|
|
|
export { BackupType };
|
|
|
|
const IV_LENGTH = 16;
|
|
|
|
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
|
|
|
export type DownloadOptionsType = Readonly<{
|
|
onProgress?: (
|
|
backupStep: InstallScreenBackupStep,
|
|
currentBytes: number,
|
|
totalBytes: number
|
|
) => void;
|
|
abortSignal?: AbortSignal;
|
|
}>;
|
|
|
|
type DoDownloadOptionsType = Readonly<{
|
|
downloadPath: string;
|
|
ephemeralKey?: Uint8Array;
|
|
onProgress?: (
|
|
backupStep: InstallScreenBackupStep,
|
|
currentBytes: number,
|
|
totalBytes: number
|
|
) => void;
|
|
}>;
|
|
|
|
export type ImportOptionsType = Readonly<{
|
|
backupType?: BackupType;
|
|
localBackupSnapshotDir?: string;
|
|
ephemeralKey?: Uint8Array;
|
|
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
|
}>;
|
|
|
|
export type ExportResultType = Readonly<{
|
|
totalBytes: number;
|
|
duration: number;
|
|
stats: Readonly<StatsType>;
|
|
}>;
|
|
|
|
export type LocalBackupExportResultType = ExportResultType & {
|
|
snapshotDir: string;
|
|
};
|
|
|
|
export type ValidationResultType = Readonly<
|
|
| {
|
|
result: ExportResultType | LocalBackupExportResultType;
|
|
}
|
|
| {
|
|
error: string;
|
|
}
|
|
>;
|
|
|
|
export class BackupsService {
|
|
#isStarted = false;
|
|
#isRunning: 'import' | 'export' | false = false;
|
|
#importController: AbortController | undefined;
|
|
#downloadController: AbortController | undefined;
|
|
|
|
#downloadRetryPromise:
|
|
| ExplodePromiseResultType<RetryBackupImportValue>
|
|
| undefined;
|
|
|
|
#localBackupSnapshotDir: string | undefined;
|
|
|
|
public readonly credentials = new BackupCredentials();
|
|
public readonly api = new BackupAPI(this.credentials);
|
|
public readonly throttledFetchCloudBackupStatus = throttle(
|
|
30 * SECOND,
|
|
this.fetchCloudBackupStatus.bind(this)
|
|
);
|
|
public readonly throttledFetchSubscriptionStatus = throttle(
|
|
30 * SECOND,
|
|
this.fetchSubscriptionStatus.bind(this)
|
|
);
|
|
|
|
public start(): void {
|
|
if (this.#isStarted) {
|
|
log.warn('BackupsService: already started');
|
|
return;
|
|
}
|
|
|
|
this.#isStarted = true;
|
|
log.info('BackupsService: starting...');
|
|
|
|
setInterval(() => {
|
|
drop(this.#runPeriodicRefresh());
|
|
}, BACKUP_REFRESH_INTERVAL);
|
|
|
|
drop(this.#runPeriodicRefresh());
|
|
this.credentials.start();
|
|
|
|
window.Whisper.events.on('userChanged', () => {
|
|
drop(this.credentials.clearCache());
|
|
this.api.clearCache();
|
|
});
|
|
}
|
|
public async downloadAndImport(
|
|
options: DownloadOptionsType
|
|
): Promise<{ wasBackupImported: boolean }> {
|
|
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
|
if (!backupDownloadPath) {
|
|
log.warn('backups.downloadAndImport: no backup download path, skipping');
|
|
return { wasBackupImported: false };
|
|
}
|
|
|
|
log.info('backups.downloadAndImport: downloading...');
|
|
|
|
const ephemeralKey = window.storage.get('backupEphemeralKey');
|
|
|
|
const absoluteDownloadPath =
|
|
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
|
|
let hasBackup = false;
|
|
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
hasBackup = await this.#doDownloadAndImport({
|
|
downloadPath: absoluteDownloadPath,
|
|
onProgress: options.onProgress,
|
|
ephemeralKey,
|
|
});
|
|
|
|
if (!hasBackup) {
|
|
// If the primary cancels sync on their end, then we can link without sync
|
|
log.info('backups.downloadAndImport: missing backup');
|
|
window.reduxActions.installer.handleMissingBackup();
|
|
}
|
|
} catch (error) {
|
|
this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
|
|
|
|
let installerError: InstallScreenBackupError;
|
|
if (error instanceof BackupInstallerError) {
|
|
log.error(
|
|
'backups.downloadAndImport: got installer error',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
({ installerError } = error);
|
|
} else {
|
|
log.error(
|
|
'backups.downloadAndImport: unknown error, prompting user to retry'
|
|
);
|
|
installerError = InstallScreenBackupError.Retriable;
|
|
}
|
|
|
|
window.reduxActions.installer.updateBackupImportProgress({
|
|
error: installerError,
|
|
});
|
|
|
|
// For download errors, wait for user confirmation to retry or unlink
|
|
const nextStep =
|
|
error instanceof BackupImportCanceledError
|
|
? 'cancel'
|
|
: // eslint-disable-next-line no-await-in-loop
|
|
await this.#downloadRetryPromise.promise;
|
|
if (nextStep === 'retry') {
|
|
log.warn('backups.downloadAndImport: retrying');
|
|
continue;
|
|
}
|
|
|
|
if (nextStep !== 'cancel') {
|
|
throw missingCaseError(nextStep);
|
|
}
|
|
|
|
// If we are here: the user has either canceled manually, or after
|
|
// getting an error (potentially fatal).
|
|
log.warn('backups.downloadAndImport: unlinking');
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await this.#unlinkAndDeleteAllData();
|
|
|
|
try {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await unlink(absoluteDownloadPath);
|
|
} catch {
|
|
// Best-effort
|
|
}
|
|
|
|
// Make sure to fail the backup import process so that background.ts
|
|
// will not wait for the syncs.
|
|
throw error;
|
|
}
|
|
break;
|
|
}
|
|
|
|
await window.storage.remove('backupDownloadPath');
|
|
await window.storage.remove('backupEphemeralKey');
|
|
await window.storage.remove('backupTransitArchive');
|
|
await window.storage.put('isRestoredFromBackup', hasBackup);
|
|
|
|
log.info('backups.downloadAndImport: done');
|
|
|
|
return { wasBackupImported: hasBackup };
|
|
}
|
|
|
|
public retryDownload(): void {
|
|
if (!this.#downloadRetryPromise) {
|
|
return;
|
|
}
|
|
|
|
this.#downloadRetryPromise.resolve('retry');
|
|
}
|
|
|
|
public async upload(): Promise<void> {
|
|
await this.#waitForEmptyQueues('backups.upload');
|
|
|
|
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
|
const filePath = join(window.BasePaths.temp, fileName);
|
|
|
|
const backupLevel = await this.credentials.getBackupLevel(
|
|
BackupCredentialType.Media
|
|
);
|
|
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
|
|
|
|
try {
|
|
const { totalBytes } = await this.exportToDisk(filePath, backupLevel);
|
|
|
|
await this.api.upload(filePath, totalBytes);
|
|
} finally {
|
|
try {
|
|
await unlink(filePath);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
public async exportLocalBackup(
|
|
backupsBaseDir: string | undefined = undefined,
|
|
backupLevel: BackupLevel = BackupLevel.Free
|
|
): Promise<LocalBackupExportResultType> {
|
|
strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled');
|
|
|
|
await this.#waitForEmptyQueues('backups.exportLocalBackup');
|
|
|
|
const baseDir =
|
|
backupsBaseDir ??
|
|
join(window.SignalContext.getPath('userData'), 'SignalBackups');
|
|
const snapshotDir = join(baseDir, `signal-backup-${new Date().getTime()}`);
|
|
await mkdir(snapshotDir, { recursive: true });
|
|
const mainProtoPath = join(snapshotDir, 'main');
|
|
|
|
log.info('exportLocalBackup: starting');
|
|
|
|
const exportResult = await this.exportToDisk(
|
|
mainProtoPath,
|
|
backupLevel,
|
|
BackupType.Ciphertext,
|
|
snapshotDir
|
|
);
|
|
|
|
log.info('exportLocalBackup: writing metadata');
|
|
const metadataArgs = {
|
|
snapshotDir,
|
|
backupId: getBackupId(),
|
|
metadataKey: getLocalBackupMetadataKey(),
|
|
};
|
|
await writeLocalBackupMetadata(metadataArgs);
|
|
await verifyLocalBackupMetadata(metadataArgs);
|
|
|
|
log.info(
|
|
'exportLocalBackup: waiting for AttachmentLocalBackupManager to finish'
|
|
);
|
|
await AttachmentLocalBackupManager.waitForIdle();
|
|
|
|
log.info(`exportLocalBackup: exported to disk: ${snapshotDir}`);
|
|
return { ...exportResult, snapshotDir };
|
|
}
|
|
|
|
public async stageLocalBackupForImport(
|
|
snapshotDir: string
|
|
): Promise<ValidateLocalBackupStructureResultType> {
|
|
const result = await validateLocalBackupStructure(snapshotDir);
|
|
const { success, error } = result;
|
|
if (success) {
|
|
this.#localBackupSnapshotDir = snapshotDir;
|
|
log.info(
|
|
`stageLocalBackupForImport: Staged ${snapshotDir} for import. Please link to perform import.`
|
|
);
|
|
} else {
|
|
this.#localBackupSnapshotDir = undefined;
|
|
log.info(
|
|
`stageLocalBackupForImport: Invalid snapshot ${snapshotDir}. Error: ${error}.`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public isLocalBackupStaged(): boolean {
|
|
return Boolean(this.#localBackupSnapshotDir);
|
|
}
|
|
|
|
public async importLocalBackup(): Promise<void> {
|
|
strictAssert(
|
|
this.#localBackupSnapshotDir,
|
|
'importLocalBackup: Staged backup is required, use stageLocalBackupForImport()'
|
|
);
|
|
|
|
log.info(`importLocalBackup: Importing ${this.#localBackupSnapshotDir}`);
|
|
|
|
const backupFile = join(this.#localBackupSnapshotDir, 'main');
|
|
await this.importFromDisk(backupFile, {
|
|
localBackupSnapshotDir: this.#localBackupSnapshotDir,
|
|
});
|
|
|
|
await verifyLocalBackupMetadata({
|
|
snapshotDir: this.#localBackupSnapshotDir,
|
|
backupId: getBackupId(),
|
|
metadataKey: getLocalBackupMetadataKey(),
|
|
});
|
|
|
|
this.#localBackupSnapshotDir = undefined;
|
|
|
|
log.info('importLocalBackup: Done');
|
|
}
|
|
|
|
// Test harness
|
|
public async exportBackupData(
|
|
backupLevel: BackupLevel = BackupLevel.Free,
|
|
backupType = BackupType.Ciphertext
|
|
): Promise<{ data: Uint8Array } & ExportResultType> {
|
|
const sink = new PassThrough();
|
|
|
|
const chunks = new Array<Uint8Array>();
|
|
sink.on('data', chunk => chunks.push(chunk));
|
|
const result = await this.#exportBackup(sink, backupLevel, backupType);
|
|
|
|
return {
|
|
...result,
|
|
data: Bytes.concatenate(chunks),
|
|
};
|
|
}
|
|
|
|
public async exportToDisk(
|
|
path: string,
|
|
backupLevel: BackupLevel = BackupLevel.Free,
|
|
backupType = BackupType.Ciphertext,
|
|
localBackupSnapshotDir: string | undefined = undefined
|
|
): Promise<ExportResultType> {
|
|
const exportResult = await this.#exportBackup(
|
|
createWriteStream(path),
|
|
backupLevel,
|
|
backupType,
|
|
localBackupSnapshotDir
|
|
);
|
|
|
|
if (backupType === BackupType.Ciphertext) {
|
|
await validateBackup(
|
|
() => new FileStream(path),
|
|
exportResult.totalBytes,
|
|
isTestOrMockEnvironment()
|
|
? ValidationType.Internal
|
|
: ValidationType.Export
|
|
);
|
|
}
|
|
|
|
return exportResult;
|
|
}
|
|
|
|
public async _internalExportLocalBackup(
|
|
backupLevel: BackupLevel = BackupLevel.Free
|
|
): Promise<ValidationResultType> {
|
|
try {
|
|
const { canceled, dirPath: backupsBaseDir } = await ipcRenderer.invoke(
|
|
'show-open-folder-dialog'
|
|
);
|
|
if (canceled || !backupsBaseDir) {
|
|
return { error: 'Backups directory not selected' };
|
|
}
|
|
|
|
const result = await this.exportLocalBackup(backupsBaseDir, backupLevel);
|
|
return { result };
|
|
} catch (error) {
|
|
return { error: Errors.toLogFormat(error) };
|
|
}
|
|
}
|
|
|
|
public async _internalStageLocalBackupForImport(): Promise<ValidateLocalBackupStructureResultType> {
|
|
const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
|
|
'show-open-folder-dialog'
|
|
);
|
|
if (canceled || !snapshotDir) {
|
|
return {
|
|
success: false,
|
|
error: 'File dialog canceled',
|
|
snapshotDir: undefined,
|
|
};
|
|
}
|
|
|
|
return this.stageLocalBackupForImport(snapshotDir);
|
|
}
|
|
|
|
// Test harness
|
|
public async _internalValidate(
|
|
backupLevel: BackupLevel = BackupLevel.Free,
|
|
backupType = BackupType.Ciphertext
|
|
): Promise<ValidationResultType> {
|
|
try {
|
|
const start = Date.now();
|
|
|
|
const recordStream = new BackupExportStream(backupType);
|
|
|
|
recordStream.run(backupLevel);
|
|
|
|
const totalBytes = await validateBackupStream(recordStream);
|
|
|
|
const duration = Date.now() - start;
|
|
|
|
return {
|
|
result: { duration, stats: recordStream.getStats(), totalBytes },
|
|
};
|
|
} catch (error) {
|
|
return { error: Errors.toLogFormat(error) };
|
|
}
|
|
}
|
|
|
|
// Test harness
|
|
public async exportWithDialog(): Promise<void> {
|
|
const { data } = await this.exportBackupData();
|
|
|
|
const { saveAttachmentToDisk } = window.Signal.Migrations;
|
|
|
|
await saveAttachmentToDisk({
|
|
name: 'backup.bin',
|
|
data,
|
|
});
|
|
}
|
|
|
|
public async importFromDisk(
|
|
backupFile: string,
|
|
options?: ImportOptionsType
|
|
): Promise<void> {
|
|
return this.importBackup(() => createReadStream(backupFile), options);
|
|
}
|
|
|
|
public cancelDownloadAndImport(): void {
|
|
if (!this.#downloadController && !this.#importController) {
|
|
log.error(
|
|
'cancelDownloadAndImport: not canceling, download or import is not running'
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (this.#downloadController) {
|
|
log.warn('cancelDownloadAndImport: canceling download');
|
|
this.#downloadController.abort();
|
|
this.#downloadController = undefined;
|
|
if (this.#downloadRetryPromise) {
|
|
this.#downloadRetryPromise.resolve('cancel');
|
|
}
|
|
}
|
|
|
|
if (this.#importController) {
|
|
log.warn('cancelDownloadAndImport: canceling import processing');
|
|
this.#importController.abort();
|
|
this.#importController = undefined;
|
|
}
|
|
}
|
|
|
|
public async importBackup(
|
|
createBackupStream: () => Readable,
|
|
{
|
|
backupType = BackupType.Ciphertext,
|
|
ephemeralKey,
|
|
onProgress,
|
|
localBackupSnapshotDir = undefined,
|
|
}: ImportOptionsType = {}
|
|
): Promise<void> {
|
|
strictAssert(!this.#isRunning, 'BackupService is already running');
|
|
|
|
window.IPC.startTrackingQueryStats();
|
|
|
|
log.info(`importBackup: starting ${backupType}...`);
|
|
this.#isRunning = 'import';
|
|
const importStart = Date.now();
|
|
|
|
await DataWriter.disableMessageInsertTriggers();
|
|
await DataWriter.disableFSync();
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
|
|
this.#importController?.abort();
|
|
this.#importController = controller;
|
|
|
|
window.ConversationController.setReadOnly(true);
|
|
|
|
const importStream = await BackupImportStream.create(
|
|
backupType,
|
|
localBackupSnapshotDir
|
|
);
|
|
if (backupType === BackupType.Ciphertext) {
|
|
const { aesKey, macKey } = getKeyMaterial(
|
|
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
|
|
);
|
|
|
|
// First pass - don't decrypt, only verify mac
|
|
let hmac = createHmac(HashType.size256, macKey);
|
|
let theirMac: Uint8Array | undefined;
|
|
let totalBytes = 0;
|
|
|
|
const sink = new PassThrough();
|
|
sink.on('data', chunk => {
|
|
totalBytes += chunk.byteLength;
|
|
});
|
|
// Discard the data in the first pass
|
|
sink.resume();
|
|
|
|
await pipeline(
|
|
createBackupStream(),
|
|
getMacAndUpdateHmac(hmac, theirMacValue => {
|
|
theirMac = theirMacValue;
|
|
}),
|
|
sink
|
|
);
|
|
|
|
if (controller.signal.aborted) {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
onProgress?.(0, totalBytes);
|
|
|
|
strictAssert(theirMac != null, 'importBackup: Missing MAC');
|
|
strictAssert(
|
|
constantTimeEqual(hmac.digest(), theirMac),
|
|
'importBackup: Bad MAC'
|
|
);
|
|
|
|
// Second pass - decrypt (but still check the mac at the end)
|
|
hmac = createHmac(HashType.size256, macKey);
|
|
|
|
const progressReporter = new PassThrough();
|
|
progressReporter.pause();
|
|
|
|
let currentBytes = 0;
|
|
progressReporter.on('data', chunk => {
|
|
currentBytes += chunk.byteLength;
|
|
onProgress?.(currentBytes, totalBytes);
|
|
});
|
|
|
|
await pipeline(
|
|
createBackupStream(),
|
|
getMacAndUpdateHmac(hmac, noop),
|
|
progressReporter,
|
|
decipherWithAesKey(aesKey),
|
|
createGunzip(),
|
|
new DelimitedStream(),
|
|
importStream,
|
|
{ signal: controller.signal }
|
|
);
|
|
|
|
strictAssert(
|
|
constantTimeEqual(hmac.digest(), theirMac),
|
|
'importBackup: Bad MAC, second pass'
|
|
);
|
|
} else if (backupType === BackupType.TestOnlyPlaintext) {
|
|
strictAssert(
|
|
isTestOrMockEnvironment(),
|
|
'Plaintext backups can be imported only in test harness'
|
|
);
|
|
strictAssert(
|
|
ephemeralKey == null,
|
|
'Plaintext backups cannot have ephemeral key'
|
|
);
|
|
await pipeline(
|
|
createBackupStream(),
|
|
new DelimitedStream(),
|
|
importStream
|
|
);
|
|
} else {
|
|
throw missingCaseError(backupType);
|
|
}
|
|
|
|
log.info('importBackup: finished...');
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
log.info('importBackup: canceled by user');
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
log.error(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
|
|
|
if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) {
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.FailedToImportBackup,
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
} finally {
|
|
window.ConversationController.setReadOnly(false);
|
|
this.#isRunning = false;
|
|
this.#importController = undefined;
|
|
|
|
await DataWriter.enableMessageInsertTriggersAndBackfill();
|
|
await DataWriter.enableFSyncAndCheckpoint();
|
|
|
|
window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
|
|
if (window.SignalCI) {
|
|
window.SignalCI.handleEvent('backupImportComplete', {
|
|
duration: Date.now() - importStart,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
|
|
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
|
|
await DataWriter.clearAllBackupCdnObjectMetadata();
|
|
|
|
let cursor: string | undefined;
|
|
const PAGE_SIZE = 1000;
|
|
let numObjects = 0;
|
|
do {
|
|
log.info('fetchAndSaveBackupCdnObjectMetadata: fetching next page');
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const listResult = await this.api.listMedia({ cursor, limit: PAGE_SIZE });
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await DataWriter.saveBackupCdnObjectMetadata(
|
|
listResult.storedMediaObjects.map(object => ({
|
|
mediaId: object.mediaId,
|
|
cdnNumber: object.cdn,
|
|
sizeOnBackupCdn: object.objectLength,
|
|
}))
|
|
);
|
|
numObjects += listResult.storedMediaObjects.length;
|
|
|
|
cursor = listResult.cursor ?? undefined;
|
|
} while (cursor);
|
|
|
|
log.info(
|
|
`fetchAndSaveBackupCdnObjectMetadata: finished fetching metadata for ${numObjects} objects`
|
|
);
|
|
}
|
|
|
|
public async getBackupCdnInfo(
|
|
mediaId: string
|
|
): Promise<
|
|
{ isInBackupTier: true; cdnNumber: number } | { isInBackupTier: false }
|
|
> {
|
|
const storedInfo = await DataReader.getBackupCdnObjectMetadata(mediaId);
|
|
if (!storedInfo) {
|
|
return { isInBackupTier: false };
|
|
}
|
|
|
|
return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber };
|
|
}
|
|
|
|
async #doDownloadAndImport({
|
|
downloadPath,
|
|
ephemeralKey,
|
|
onProgress,
|
|
}: DoDownloadOptionsType): Promise<boolean> {
|
|
const controller = new AbortController();
|
|
|
|
// Abort previous download
|
|
this.#downloadController?.abort();
|
|
this.#downloadController = controller;
|
|
|
|
let downloadOffset = 0;
|
|
try {
|
|
({ size: downloadOffset } = await stat(downloadPath));
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
|
|
// File is missing - start from the beginning
|
|
}
|
|
|
|
const onDownloadProgress = (
|
|
currentBytes: number,
|
|
totalBytes: number
|
|
): void => {
|
|
onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes);
|
|
};
|
|
|
|
await ensureFile(downloadPath);
|
|
if (controller.signal.aborted) {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
let stream: Readable;
|
|
|
|
try {
|
|
if (ephemeralKey == null) {
|
|
stream = await this.api.download({
|
|
downloadOffset,
|
|
onProgress: onDownloadProgress,
|
|
abortSignal: controller.signal,
|
|
});
|
|
} else {
|
|
let archive = window.storage.get('backupTransitArchive');
|
|
if (archive == null) {
|
|
const response = await this.api.getTransferArchive(controller.signal);
|
|
if ('error' in response) {
|
|
switch (response.error) {
|
|
case 'RELINK_REQUESTED':
|
|
throw new RelinkRequestedError();
|
|
|
|
// Primary decided to abort syncing process; continue on with no backup
|
|
case 'CONTINUE_WITHOUT_UPLOAD':
|
|
log.error(
|
|
'backups.doDownloadAndImport: primary requested to continue without syncing'
|
|
);
|
|
return false;
|
|
default:
|
|
throw missingCaseError(response.error);
|
|
}
|
|
}
|
|
|
|
archive = {
|
|
cdn: response.cdn,
|
|
key: response.key,
|
|
};
|
|
await window.storage.put('backupTransitArchive', archive);
|
|
}
|
|
|
|
stream = await this.api.downloadEphemeral({
|
|
archive,
|
|
downloadOffset,
|
|
onProgress: onDownloadProgress,
|
|
abortSignal: controller.signal,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
// No backup on the server
|
|
if (error instanceof HTTPError && error.code === 404) {
|
|
return false;
|
|
}
|
|
|
|
if (error instanceof BackupInstallerError) {
|
|
throw error;
|
|
}
|
|
|
|
log.error(
|
|
'backups.doDownloadAndImport: error downloading backup file',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
throw new BackupDownloadFailedError();
|
|
}
|
|
|
|
if (controller.signal.aborted) {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
try {
|
|
await pipeline(
|
|
stream,
|
|
createWriteStream(downloadPath, {
|
|
flags: 'a',
|
|
start: downloadOffset,
|
|
})
|
|
);
|
|
|
|
if (controller.signal.aborted) {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
this.#downloadController = undefined;
|
|
|
|
try {
|
|
// Import and start writing to the DB. Make sure we are unlinked
|
|
// if the import process is aborted due to error or restart.
|
|
const password = window.storage.get('password');
|
|
strictAssert(password != null, 'Must be registered to import backup');
|
|
|
|
await window.storage.remove('password');
|
|
|
|
await this.importFromDisk(downloadPath, {
|
|
ephemeralKey,
|
|
onProgress: (currentBytes, totalBytes) => {
|
|
onProgress?.(
|
|
InstallScreenBackupStep.Process,
|
|
currentBytes,
|
|
totalBytes
|
|
);
|
|
},
|
|
});
|
|
|
|
// Restore password on success
|
|
await window.storage.put('password', password);
|
|
} catch (e) {
|
|
// Error or manual cancel during import; this is non-retriable
|
|
if (e instanceof BackupInstallerError) {
|
|
throw e;
|
|
} else {
|
|
throw new BackupProcessingError(e);
|
|
}
|
|
} finally {
|
|
await unlink(downloadPath);
|
|
}
|
|
} catch (error) {
|
|
// Download canceled
|
|
if (error.name === 'AbortError') {
|
|
throw new BackupImportCanceledError();
|
|
}
|
|
|
|
// Other errors bubble up and can be retried
|
|
throw error;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async #exportBackup(
|
|
sink: Writable,
|
|
backupLevel: BackupLevel = BackupLevel.Free,
|
|
backupType = BackupType.Ciphertext,
|
|
localBackupSnapshotDir: string | undefined = undefined
|
|
): Promise<ExportResultType> {
|
|
strictAssert(!this.#isRunning, 'BackupService is already running');
|
|
|
|
log.info('exportBackup: starting...');
|
|
this.#isRunning = 'export';
|
|
|
|
const start = Date.now();
|
|
try {
|
|
// TODO (DESKTOP-7168): Update mock-server to support this endpoint
|
|
if (window.SignalCI || backupType === BackupType.TestOnlyPlaintext) {
|
|
strictAssert(
|
|
isTestOrMockEnvironment(),
|
|
'Plaintext backups can be exported only in test harness'
|
|
);
|
|
} else {
|
|
// We first fetch the latest info on what's on the CDN, since this affects the
|
|
// filePointers we will generate during export
|
|
log.info('Fetching latest backup CDN metadata');
|
|
await this.fetchAndSaveBackupCdnObjectMetadata();
|
|
}
|
|
|
|
const { aesKey, macKey } = getKeyMaterial();
|
|
const recordStream = new BackupExportStream(backupType);
|
|
|
|
recordStream.run(backupLevel, localBackupSnapshotDir);
|
|
|
|
const iv = randomBytes(IV_LENGTH);
|
|
|
|
let totalBytes = 0;
|
|
|
|
if (backupType === BackupType.Ciphertext) {
|
|
await pipeline(
|
|
recordStream,
|
|
createGzip(),
|
|
appendPaddingStream(),
|
|
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
|
prependStream(iv),
|
|
appendMacStream(macKey),
|
|
measureSize({
|
|
onComplete: size => {
|
|
totalBytes = size;
|
|
},
|
|
}),
|
|
sink
|
|
);
|
|
} else if (backupType === BackupType.TestOnlyPlaintext) {
|
|
strictAssert(
|
|
isTestOrMockEnvironment(),
|
|
'Plaintext backups can be exported only in test harness'
|
|
);
|
|
await pipeline(recordStream, sink);
|
|
} else {
|
|
throw missingCaseError(backupType);
|
|
}
|
|
|
|
if (localBackupSnapshotDir) {
|
|
log.info('exportBackup: writing local backup files list');
|
|
const filesWritten = await writeLocalBackupFilesList({
|
|
snapshotDir: localBackupSnapshotDir,
|
|
mediaNamesIterator: recordStream.getMediaNamesIterator(),
|
|
});
|
|
const filesRead = await readLocalBackupFilesList(
|
|
localBackupSnapshotDir
|
|
);
|
|
strictAssert(
|
|
isEqual(filesWritten, filesRead),
|
|
'exportBackup: Local backup files proto must match files written'
|
|
);
|
|
}
|
|
|
|
const duration = Date.now() - start;
|
|
return { totalBytes, stats: recordStream.getStats(), duration };
|
|
} finally {
|
|
log.info('exportBackup: finished...');
|
|
this.#isRunning = false;
|
|
}
|
|
}
|
|
|
|
async #runPeriodicRefresh(): Promise<void> {
|
|
try {
|
|
await this.api.refresh();
|
|
log.info('Backup: refreshed');
|
|
} catch (error) {
|
|
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
|
|
}
|
|
await this.refreshBackupAndSubscriptionStatus();
|
|
}
|
|
|
|
async #unlinkAndDeleteAllData() {
|
|
window.reduxActions.installer.updateBackupImportProgress({
|
|
error: InstallScreenBackupError.Canceled,
|
|
});
|
|
|
|
try {
|
|
await window.textsecure.server?.unlink();
|
|
} catch (e) {
|
|
log.warn(
|
|
'Error while unlinking; this may be expected for the unlink operation',
|
|
Errors.toLogFormat(e)
|
|
);
|
|
}
|
|
|
|
try {
|
|
log.info('backups.unlinkAndDeleteAllData: deleting all data');
|
|
await window.textsecure.storage.protocol.removeAllData();
|
|
log.info('backups.unlinkAndDeleteAllData: all data deleted successfully');
|
|
} catch (e) {
|
|
log.error(
|
|
'backups.unlinkAndDeleteAllData: unable to remove all data',
|
|
Errors.toLogFormat(e)
|
|
);
|
|
}
|
|
|
|
// The QR code should be regenerated only after all data is cleared to prevent
|
|
// a race where the QR code doesn't show the backup capability
|
|
window.reduxActions.installer.startInstaller();
|
|
}
|
|
|
|
async #waitForEmptyQueues(
|
|
reason: 'backups.upload' | 'backups.exportLocalBackup'
|
|
) {
|
|
// Make sure we are up-to-date on storage service
|
|
{
|
|
const { promise: storageService, resolve } = explodePromise<void>();
|
|
window.Whisper.events.once('storageService:syncComplete', resolve);
|
|
|
|
runStorageServiceSyncJob({ reason });
|
|
await storageService;
|
|
}
|
|
|
|
// Clear message queue
|
|
await window.waitForEmptyEventQueue();
|
|
|
|
// Make sure all batches are flushed
|
|
await Promise.all([
|
|
window.waitForAllBatchers(),
|
|
window.flushAllWaitBatchers(),
|
|
]);
|
|
}
|
|
|
|
public isImportRunning(): boolean {
|
|
return this.#isRunning === 'import';
|
|
}
|
|
public isExportRunning(): boolean {
|
|
return this.#isRunning === 'export';
|
|
}
|
|
|
|
#getBackupTierFromStorage(): BackupLevel | null {
|
|
const backupTier = window.storage.get('backupTier');
|
|
switch (backupTier) {
|
|
case BackupLevel.Free:
|
|
return BackupLevel.Free;
|
|
case BackupLevel.Paid:
|
|
return BackupLevel.Paid;
|
|
case undefined:
|
|
return null;
|
|
default:
|
|
log.error('Unknown backupTier in storage', backupTier);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async fetchCloudBackupStatus(): Promise<BackupStatusType | undefined> {
|
|
let result: BackupStatusType | undefined;
|
|
const backupProtoInfo = await this.api.getBackupProtoInfo();
|
|
|
|
if (backupProtoInfo.backupExists) {
|
|
const { createdAt, size: protoSize } = backupProtoInfo;
|
|
result = {
|
|
createdTimestamp: createdAt.getTime(),
|
|
protoSize,
|
|
};
|
|
}
|
|
|
|
await window.storage.put('cloudBackupStatus', result);
|
|
return result;
|
|
}
|
|
|
|
async fetchSubscriptionStatus(): Promise<
|
|
BackupsSubscriptionType | undefined
|
|
> {
|
|
const backupTier = this.#getBackupTierFromStorage();
|
|
let result: BackupsSubscriptionType;
|
|
switch (backupTier) {
|
|
case null:
|
|
case undefined:
|
|
result = {
|
|
status: 'off',
|
|
};
|
|
break;
|
|
case BackupLevel.Free:
|
|
result = {
|
|
status: 'free',
|
|
mediaIncludedInBackupDurationDays: getMessageQueueTime() / DAY,
|
|
};
|
|
break;
|
|
case BackupLevel.Paid:
|
|
result = await this.api.getSubscriptionInfo();
|
|
break;
|
|
default:
|
|
throw missingCaseError(backupTier);
|
|
}
|
|
|
|
drop(window.storage.put('backupSubscriptionStatus', result));
|
|
return result;
|
|
}
|
|
|
|
async refreshBackupAndSubscriptionStatus(): Promise<void> {
|
|
await Promise.all([
|
|
this.fetchSubscriptionStatus(),
|
|
this.fetchCloudBackupStatus(),
|
|
]);
|
|
}
|
|
|
|
hasMediaBackups(): boolean {
|
|
return window.storage.get('backupTier') === BackupLevel.Paid;
|
|
}
|
|
|
|
getCachedCloudBackupStatus(): BackupStatusType | undefined {
|
|
return window.storage.get('cloudBackupStatus');
|
|
}
|
|
|
|
async pickLocalBackupFolder(): Promise<string | undefined> {
|
|
const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
|
|
'show-open-folder-dialog'
|
|
);
|
|
if (canceled || !snapshotDir) {
|
|
return;
|
|
}
|
|
|
|
drop(window.storage.put('localBackupFolder', snapshotDir));
|
|
return snapshotDir;
|
|
}
|
|
}
|
|
|
|
export const backupsService = new BackupsService();
|