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

172 lines
4.8 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';
2024-03-15 14:20:33 +00:00
import { createWriteStream } from 'fs';
2024-04-15 20:54:21 +00:00
import { createGzip, createGunzip } from 'zlib';
import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash';
2024-03-15 14:20:33 +00:00
import * as log from '../../logging/log';
import * as Bytes from '../../Bytes';
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';
import { toAciObject } from '../../util/ServiceId';
import { CipherType, HashType } from '../../types/Crypto';
2024-03-15 14:20:33 +00:00
import * as Errors from '../../types/errors';
2024-04-15 20:54:21 +00:00
import {
deriveBackupKey,
deriveBackupId,
deriveBackupKeyMaterial,
constantTimeEqual,
} from '../../Crypto';
import type { BackupKeyMaterialType } from '../../Crypto';
import { getIvAndDecipher, getMacAndUpdateHmac } from '../../AttachmentCrypto';
2024-03-15 14:20:33 +00:00
import { BackupExportStream } from './export';
import { BackupImportStream } from './import';
2024-04-15 20:54:21 +00:00
const IV_LENGTH = 16;
function getKeyMaterial(): BackupKeyMaterialType {
const masterKey = window.storage.get('masterKey');
if (!masterKey) {
throw new Error('Master key not available');
}
const aci = toAciObject(window.storage.user.getCheckedAci());
const aciBytes = aci.getServiceIdBinary();
const backupKey = deriveBackupKey(Bytes.fromBase64(masterKey));
const backupId = deriveBackupId(backupKey, aciBytes);
return deriveBackupKeyMaterial(backupKey, backupId);
}
2024-03-15 14:20:33 +00:00
export class BackupsService {
private isRunning = false;
2024-04-15 20:54:21 +00:00
public async exportBackup(sink: Writable): Promise<void> {
2024-03-15 14:20:33 +00:00
if (this.isRunning) {
throw new Error('BackupService is already running');
}
log.info('exportBackup: starting...');
this.isRunning = true;
2024-04-15 20:54:21 +00:00
try {
const { aesKey, macKey } = getKeyMaterial();
2024-03-15 14:20:33 +00:00
2024-04-15 20:54:21 +00:00
const recordStream = new BackupExportStream();
recordStream.run();
2024-03-15 14:20:33 +00:00
2024-04-15 20:54:21 +00:00
const iv = randomBytes(IV_LENGTH);
2024-03-15 14:20:33 +00:00
2024-04-15 20:54:21 +00:00
await pipeline(
recordStream,
createGzip(),
appendPaddingStream(),
createCipheriv(CipherType.AES256CBC, aesKey, iv),
prependStream(iv),
appendMacStream(macKey),
sink
);
} finally {
log.info('exportBackup: finished...');
this.isRunning = false;
}
2024-03-15 14:20:33 +00:00
}
// Test harness
public async exportBackupData(): 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));
await this.exportBackup(sink);
2024-03-15 14:20:33 +00:00
return Bytes.concatenate(chunks);
}
// Test harness
public async exportToDisk(path: string): Promise<void> {
2024-04-15 20:54:21 +00:00
await this.exportBackup(createWriteStream(path));
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,
});
}
2024-04-15 20:54:21 +00:00
public async importBackup(createBackupStream: () => Readable): Promise<void> {
2024-03-15 14:20:33 +00:00
if (this.isRunning) {
throw new Error('BackupService is already running');
}
log.info('importBackup: starting...');
this.isRunning = true;
try {
2024-04-15 20:54:21 +00:00
const { aesKey, macKey } = getKeyMaterial();
// First pass - don't decrypt, only verify mac
let hmac = createHmac(HashType.size256, macKey);
let theirMac: Uint8Array | undefined;
const sink = new PassThrough();
// Discard the data in the first pass
sink.resume();
await pipeline(
createBackupStream(),
getMacAndUpdateHmac(hmac, theirMacValue => {
theirMac = theirMacValue;
}),
sink
);
if (theirMac == null) {
throw new Error('importBackup: Missing MAC');
}
if (!constantTimeEqual(hmac.digest(), theirMac)) {
throw new Error('importBackup: Bad MAC');
}
// Second pass - decrypt (but still check the mac at the end)
hmac = createHmac(HashType.size256, macKey);
2024-03-15 14:20:33 +00:00
await pipeline(
2024-04-15 20:54:21 +00:00
createBackupStream(),
getMacAndUpdateHmac(hmac, noop),
getIvAndDecipher(aesKey),
createGunzip(),
2024-03-15 14:20:33 +00:00
new DelimitedStream(),
new BackupImportStream()
);
2024-04-15 20:54:21 +00:00
if (!constantTimeEqual(hmac.digest(), theirMac)) {
throw new Error('importBackup: Bad MAC, second pass');
}
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;
}
}
}
export const backupsService = new BackupsService();