signal-desktop/ts/services/backups/index.ts

600 lines
17 KiB
TypeScript
Raw Normal View History

2024-03-15 14:20:33 +00:00
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { pipeline } from 'stream/promises';
2024-04-15 20:54:21 +00:00
import { PassThrough } from 'stream';
import type { Readable, Writable } from 'stream';
import { createReadStream, createWriteStream } from 'fs';
2024-08-27 21:00:41 +00:00
import { unlink, stat } from 'fs/promises';
import { ensureFile } from 'fs-extra';
2024-05-14 17:04:50 +00:00
import { join } from 'path';
2024-04-15 20:54:21 +00:00
import { createGzip, createGunzip } from 'zlib';
import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
2024-10-31 17:01:03 +00:00
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
2024-03-15 14:20:33 +00:00
2024-07-22 18:16:33 +00:00
import { DataReader, DataWriter } from '../../sql/Client';
2024-03-15 14:20:33 +00:00
import * as log from '../../logging/log';
import * as Bytes from '../../Bytes';
2024-04-22 14:11:36 +00:00
import { strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
2024-03-15 14:20:33 +00:00
import { DelimitedStream } from '../../util/DelimitedStream';
2024-04-15 20:54:21 +00:00
import { appendPaddingStream } from '../../util/logPadding';
import { prependStream } from '../../util/prependStream';
import { appendMacStream } from '../../util/appendMacStream';
2024-07-11 19:44:09 +00:00
import { getIvAndDecipher } from '../../util/getIvAndDecipher';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
2024-09-03 17:18:15 +00:00
import { missingCaseError } from '../../util/missingCaseError';
2024-04-22 14:11:36 +00:00
import { HOUR } from '../../util/durations';
2024-04-15 20:54:21 +00:00
import { CipherType, HashType } from '../../types/Crypto';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
2024-03-15 14:20:33 +00:00
import * as Errors from '../../types/errors';
2024-10-31 17:01:03 +00:00
import { BackupCredentialType } from '../../types/backups';
2024-08-27 21:00:41 +00:00
import { HTTPError } from '../../textsecure/Errors';
2024-04-22 14:11:36 +00:00
import { constantTimeEqual } from '../../Crypto';
2024-07-11 19:44:09 +00:00
import { measureSize } from '../../AttachmentCrypto';
2024-09-03 17:18:15 +00:00
import { isTestOrMockEnvironment } from '../../environment';
2024-03-15 14:20:33 +00:00
import { BackupExportStream } from './export';
import { BackupImportStream } from './import';
2024-04-22 14:11:36 +00:00
import { getKeyMaterial } from './crypto';
import { BackupCredentials } from './credentials';
import { BackupAPI } from './api';
2024-05-14 17:04:50 +00:00
import { validateBackup } from './validator';
2024-09-21 03:10:28 +00:00
import { BackupType } from './types';
import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer';
2024-09-21 03:10:28 +00:00
export { BackupType };
2024-03-15 14:20:33 +00:00
2024-04-15 20:54:21 +00:00
const IV_LENGTH = 16;
2024-04-22 14:11:36 +00:00
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
2024-04-15 20:54:21 +00:00
export type DownloadOptionsType = Readonly<{
onProgress?: (
backupStep: InstallScreenBackupStep,
currentBytes: number,
totalBytes: number
) => void;
abortSignal?: AbortSignal;
}>;
2024-10-18 17:15:03 +00:00
type DoDownloadOptionsType = Readonly<{
downloadPath: string;
ephemeralKey?: Uint8Array;
onProgress?: (
backupStep: InstallScreenBackupStep,
currentBytes: number,
totalBytes: number
) => void;
}>;
export type ImportOptionsType = Readonly<{
backupType?: BackupType;
2024-10-18 17:15:03 +00:00
ephemeralKey?: Uint8Array;
onProgress?: (currentBytes: number, totalBytes: number) => void;
}>;
2024-03-15 14:20:33 +00:00
export class BackupsService {
2024-04-22 14:11:36 +00:00
private isStarted = false;
2024-03-15 14:20:33 +00:00
private isRunning = false;
2024-09-11 18:03:18 +00:00
private downloadController: AbortController | undefined;
private downloadRetryPromise:
| ExplodePromiseResultType<RetryBackupImportValue>
| undefined;
2024-03-15 14:20:33 +00:00
2024-04-22 14:11:36 +00:00
public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials);
public start(): void {
2024-04-22 21:25:56 +00:00
if (this.isStarted) {
log.warn('BackupsService: already started');
return;
}
2024-04-22 14:11:36 +00:00
this.isStarted = true;
2024-04-22 21:25:56 +00:00
log.info('BackupsService: starting...');
2024-04-22 14:11:36 +00:00
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();
});
2024-04-22 14:11:36 +00:00
}
public async download(options: DownloadOptionsType): Promise<void> {
const backupDownloadPath = window.storage.get('backupDownloadPath');
if (!backupDownloadPath) {
log.warn('backups.download: no backup download path, skipping');
return;
}
2024-10-18 17:15:03 +00:00
log.info('backups.download: 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
2024-10-18 17:15:03 +00:00
hasBackup = await this.doDownload({
downloadPath: absoluteDownloadPath,
onProgress: options.onProgress,
ephemeralKey,
});
} catch (error) {
log.warn(
'backups.download: error, prompting user to retry',
Errors.toLogFormat(error)
);
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>();
window.reduxActions.installer.updateBackupImportProgress({
hasError: true,
});
// eslint-disable-next-line no-await-in-loop
const nextStep = await this.downloadRetryPromise.promise;
if (nextStep === 'retry') {
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await unlink(absoluteDownloadPath);
} catch {
// Best-effort
}
}
break;
}
await window.storage.remove('backupDownloadPath');
2024-10-18 17:15:03 +00:00
await window.storage.remove('backupEphemeralKey');
await window.storage.put('isRestoredFromBackup', hasBackup);
log.info(`backups.download: done, had backup=${hasBackup}`);
}
public retryDownload(): void {
if (!this.downloadRetryPromise) {
return;
}
this.downloadRetryPromise.resolve('retry');
}
2024-05-14 17:04:50 +00:00
public async upload(): Promise<void> {
const fileName = `backup-${randomBytes(32).toString('hex')}`;
const filePath = join(window.BasePaths.temp, fileName);
2024-03-15 14:20:33 +00:00
2024-10-31 17:01:03 +00:00
const backupLevel = await this.credentials.getBackupLevel(
BackupCredentialType.Media
);
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
2024-04-15 20:54:21 +00:00
try {
const fileSize = await this.exportToDisk(filePath, backupLevel);
2024-03-15 14:20:33 +00:00
2024-05-14 17:04:50 +00:00
await this.api.upload(filePath, fileSize);
2024-04-15 20:54:21 +00:00
} finally {
2024-05-14 17:04:50 +00:00
try {
await unlink(filePath);
} catch {
// Ignore
}
2024-04-15 20:54:21 +00:00
}
2024-03-15 14:20:33 +00:00
}
// Test harness
public async exportBackupData(
2024-10-31 17:01:03 +00:00
backupLevel: BackupLevel = BackupLevel.Free,
2024-09-21 03:10:28 +00:00
backupType = BackupType.Ciphertext
): Promise<Uint8Array> {
2024-04-15 20:54:21 +00:00
const sink = new PassThrough();
2024-03-15 14:20:33 +00:00
const chunks = new Array<Uint8Array>();
2024-04-15 20:54:21 +00:00
sink.on('data', chunk => chunks.push(chunk));
2024-09-21 03:10:28 +00:00
await this.exportBackup(sink, backupLevel, backupType);
2024-03-15 14:20:33 +00:00
return Bytes.concatenate(chunks);
}
// Test harness
public async exportToDisk(
path: string,
2024-10-31 17:01:03 +00:00
backupLevel: BackupLevel = BackupLevel.Free,
2024-09-03 17:18:15 +00:00
backupType = BackupType.Ciphertext
): Promise<number> {
2024-09-03 17:18:15 +00:00
const size = await this.exportBackup(
createWriteStream(path),
backupLevel,
backupType
);
2024-05-14 17:04:50 +00:00
2024-09-03 17:18:15 +00:00
if (backupType === BackupType.Ciphertext) {
await validateBackup(path, size);
}
2024-05-14 17:04:50 +00:00
return size;
2024-03-15 14:20:33 +00:00
}
// 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);
}
2024-09-11 18:03:18 +00:00
public cancelDownload(): void {
if (this.downloadController) {
log.warn('importBackup: canceling download');
this.downloadController.abort();
this.downloadController = undefined;
if (this.downloadRetryPromise) {
this.downloadRetryPromise.resolve('cancel');
}
2024-09-11 18:03:18 +00:00
} else {
log.error('importBackup: not canceling download, not running');
}
}
2024-09-03 17:18:15 +00:00
public async importBackup(
createBackupStream: () => Readable,
2024-10-18 17:15:03 +00:00
{
backupType = BackupType.Ciphertext,
ephemeralKey,
onProgress,
}: ImportOptionsType = {}
2024-09-03 17:18:15 +00:00
): Promise<void> {
2024-04-22 14:11:36 +00:00
strictAssert(!this.isRunning, 'BackupService is already running');
2024-03-15 14:20:33 +00:00
2024-10-28 17:11:19 +00:00
window.IPC.startTrackingQueryStats();
2024-09-03 17:18:15 +00:00
log.info(`importBackup: starting ${backupType}...`);
2024-03-15 14:20:33 +00:00
this.isRunning = true;
try {
2024-09-21 03:10:28 +00:00
const importStream = await BackupImportStream.create(backupType);
2024-09-03 17:18:15 +00:00
if (backupType === BackupType.Ciphertext) {
2024-10-31 17:01:03 +00:00
const { aesKey, macKey } = getKeyMaterial(
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
);
2024-09-03 17:18:15 +00:00
// First pass - don't decrypt, only verify mac
let hmac = createHmac(HashType.size256, macKey);
let theirMac: Uint8Array | undefined;
let totalBytes = 0;
2024-09-03 17:18:15 +00:00
const sink = new PassThrough();
sink.on('data', chunk => {
totalBytes += chunk.byteLength;
});
2024-09-03 17:18:15 +00:00
// Discard the data in the first pass
sink.resume();
await pipeline(
createBackupStream(),
getMacAndUpdateHmac(hmac, theirMacValue => {
theirMac = theirMacValue;
}),
sink
);
onProgress?.(0, totalBytes);
2024-09-03 17:18:15 +00:00
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);
});
2024-09-03 17:18:15 +00:00
await pipeline(
createBackupStream(),
getMacAndUpdateHmac(hmac, noop),
progressReporter,
2024-09-03 17:18:15 +00:00
getIvAndDecipher(aesKey),
createGunzip(),
new DelimitedStream(),
2024-09-04 02:56:13 +00:00
importStream
2024-09-03 17:18:15 +00:00
);
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'
);
2024-10-18 17:15:03 +00:00
strictAssert(
ephemeralKey == null,
'Plaintext backups cannot have ephemeral key'
);
2024-09-03 17:18:15 +00:00
await pipeline(
createBackupStream(),
new DelimitedStream(),
2024-09-04 02:56:13 +00:00
importStream
2024-09-03 17:18:15 +00:00
);
} else {
throw missingCaseError(backupType);
}
2024-04-15 20:54:21 +00:00
2024-03-15 14:20:33 +00:00
log.info('importBackup: finished...');
} catch (error) {
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
throw error;
} finally {
this.isRunning = false;
2024-10-28 17:11:19 +00:00
window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
if (window.SignalCI) {
window.SignalCI.handleEvent('backupImportComplete', null);
}
2024-03-15 14:20:33 +00:00
}
}
2024-04-22 14:11:36 +00:00
2024-05-29 23:46:43 +00:00
public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
2024-07-22 18:16:33 +00:00
await DataWriter.clearAllBackupCdnObjectMetadata();
2024-05-29 23:46:43 +00:00
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
2024-07-22 18:16:33 +00:00
await DataWriter.saveBackupCdnObjectMetadata(
2024-05-29 23:46:43 +00:00
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 }
> {
2024-07-22 18:16:33 +00:00
const storedInfo = await DataReader.getBackupCdnObjectMetadata(mediaId);
2024-05-29 23:46:43 +00:00
if (!storedInfo) {
return { isInBackupTier: false };
}
return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber };
}
2024-10-18 17:15:03 +00:00
private async doDownload({
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
}
2024-10-18 17:15:03 +00:00
const onDownloadProgress = (
currentBytes: number,
totalBytes: number
): void => {
onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes);
};
try {
await ensureFile(downloadPath);
if (controller.signal.aborted) {
return false;
}
2024-10-18 17:15:03 +00:00
let stream: Readable;
if (ephemeralKey == null) {
stream = await this.api.download({
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
} else {
stream = await this.api.downloadEphemeral({
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
}
if (controller.signal.aborted) {
return false;
}
await pipeline(
stream,
createWriteStream(downloadPath, {
flags: 'a',
start: downloadOffset,
})
);
if (controller.signal.aborted) {
return false;
}
this.downloadController = undefined;
try {
2024-10-29 15:24:41 +00:00
// Too late to cancel now, make sure we are unlinked if the 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, {
2024-10-18 17:15:03 +00:00
ephemeralKey,
onProgress: (currentBytes, totalBytes) => {
onProgress?.(
InstallScreenBackupStep.Process,
currentBytes,
totalBytes
);
},
});
2024-10-29 15:24:41 +00:00
// Restore password on success
await window.storage.put('password', password);
} finally {
await unlink(downloadPath);
}
} catch (error) {
// Download canceled
if (error.name === 'AbortError') {
return false;
}
// No backup on the server
if (error instanceof HTTPError && error.code === 404) {
return false;
}
// Other errors bubble up and can be retried
throw error;
}
return true;
}
private async exportBackup(
sink: Writable,
2024-10-31 17:01:03 +00:00
backupLevel: BackupLevel = BackupLevel.Free,
2024-09-03 17:18:15 +00:00
backupType = BackupType.Ciphertext
): Promise<number> {
2024-05-14 17:04:50 +00:00
strictAssert(!this.isRunning, 'BackupService is already running');
log.info('exportBackup: starting...');
this.isRunning = true;
try {
2024-05-29 23:46:43 +00:00
// TODO (DESKTOP-7168): Update mock-server to support this endpoint
2024-09-21 03:10:28 +00:00
if (window.SignalCI || backupType === BackupType.TestOnlyPlaintext) {
strictAssert(
isTestOrMockEnvironment(),
'Plaintext backups can be exported only in test harness'
);
} else {
2024-05-29 23:46:43 +00:00
// 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();
}
2024-05-14 17:04:50 +00:00
2024-05-29 23:46:43 +00:00
const { aesKey, macKey } = getKeyMaterial();
2024-09-21 03:10:28 +00:00
const recordStream = new BackupExportStream(backupType);
2024-05-29 23:46:43 +00:00
recordStream.run(backupLevel);
2024-05-14 17:04:50 +00:00
const iv = randomBytes(IV_LENGTH);
let totalBytes = 0;
2024-09-03 17:18:15 +00:00
if (backupType === BackupType.Ciphertext) {
await pipeline(
recordStream,
createGzip(),
appendPaddingStream(),
createCipheriv(CipherType.AES256CBC, aesKey, iv),
prependStream(iv),
appendMacStream(macKey),
measureSize(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);
}
2024-05-14 17:04:50 +00:00
return totalBytes;
} finally {
log.info('exportBackup: finished...');
this.isRunning = false;
}
}
2024-04-22 14:11:36 +00:00
private async runPeriodicRefresh(): Promise<void> {
try {
await this.api.refresh();
log.info('Backup: refreshed');
} catch (error) {
2024-08-08 19:22:48 +00:00
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
2024-04-22 14:11:36 +00:00
}
}
2024-03-15 14:20:33 +00:00
}
export const backupsService = new BackupsService();