Files
Desktop/ts/services/backups/util/localBackup.node.ts
2026-03-09 15:36:36 -07:00

260 lines
7.0 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createLogger } from '../../../logging/log.std.js';
import * as Bytes from '../../../Bytes.std.js';
import * as Errors from '../../../types/errors.std.js';
import { Signal } from '../../../protobuf/index.std.js';
import { DelimitedStream } from '../../../util/DelimitedStream.node.js';
import { strictAssert } from '../../../util/assert.std.js';
import { decryptAesCtr, encryptAesCtr } from '../../../Crypto.node.js';
import type { LocalBackupMetadataVerificationType } from '../../../types/backups.node.js';
import {
LOCAL_BACKUP_VERSION,
LOCAL_BACKUP_BACKUP_ID_IV_LENGTH,
} from '../constants.std.js';
const log = createLogger('localBackup');
export function getLocalBackupFilesDirectory({
backupsBaseDir,
}: {
backupsBaseDir: string;
}): string {
return join(backupsBaseDir, 'files');
}
export async function getAllPathsInLocalBackupFilesDirectory({
backupsBaseDir,
}: {
backupsBaseDir: string;
}): Promise<Array<string>> {
const filesDir = getLocalBackupFilesDirectory({ backupsBaseDir });
const allEntries = await readdir(filesDir, {
withFileTypes: true,
recursive: true,
});
return allEntries
.filter(entry => entry.isFile())
.map(entry => join(entry.parentPath, entry.name));
}
export function getLocalBackupDirectoryForMediaName({
backupsBaseDir,
mediaName,
}: {
backupsBaseDir: string;
mediaName: string;
}): string {
if (mediaName.length < 2) {
throw new Error('Invalid mediaName input');
}
return join(
getLocalBackupFilesDirectory({ backupsBaseDir }),
mediaName.substring(0, 2)
);
}
export function getLocalBackupPathForMediaName({
backupsBaseDir,
mediaName,
}: {
backupsBaseDir: string;
mediaName: string;
}): string {
return join(
getLocalBackupDirectoryForMediaName({ backupsBaseDir, mediaName }),
mediaName
);
}
/**
* Given a target local backup import e.g. /etc/SignalBackups/signal-backup-1743119037066
* and an attachment, return the attachment's file path within the local backup
* e.g. /etc/SignalBackups/files/a1/[a1bcdef...]
*
* @param {string} snapshotDir - Timestamped local backup directory
*/
export function getAttachmentLocalBackupPathFromSnapshotDir(
mediaName: string,
snapshotDir: string
): string {
return join(
dirname(snapshotDir),
'files',
mediaName.substring(0, 2),
mediaName
);
}
export async function writeLocalBackupMetadata({
snapshotDir,
backupId,
metadataKey,
}: LocalBackupMetadataVerificationType): Promise<void> {
const iv = randomBytes(LOCAL_BACKUP_BACKUP_ID_IV_LENGTH);
const encryptedId = encryptAesCtr(metadataKey, backupId, iv);
const metadataSerialized = Signal.backup.local.Metadata.encode({
backupId: new Signal.backup.local.Metadata.EncryptedBackupId({
iv,
encryptedId,
}),
version: LOCAL_BACKUP_VERSION,
}).finish();
const metadataPath = join(snapshotDir, 'metadata');
await writeFile(metadataPath, metadataSerialized);
}
export async function verifyLocalBackupMetadata({
snapshotDir,
backupId,
metadataKey,
}: LocalBackupMetadataVerificationType): Promise<boolean> {
const metadataPath = join(snapshotDir, 'metadata');
const metadataSerialized = await readFile(metadataPath);
const metadata = Signal.backup.local.Metadata.decode(metadataSerialized);
strictAssert(
metadata.version === LOCAL_BACKUP_VERSION,
'verifyLocalBackupMetadata: Local backup version must match'
);
strictAssert(
metadata.backupId,
'verifyLocalBackupMetadata: Must have backupId'
);
const { iv, encryptedId } = metadata.backupId;
strictAssert(iv, 'verifyLocalBackupMetadata: Must have backupId.iv');
strictAssert(
encryptedId,
'verifyLocalBackupMetadata: Must have backupId.encryptedId'
);
const localBackupBackupId = decryptAesCtr(metadataKey, encryptedId, iv);
strictAssert(
Bytes.areEqual(backupId, localBackupBackupId),
'verifyLocalBackupMetadata: backupId must match the local backup backupId'
);
return true;
}
export async function writeLocalBackupFilesList({
snapshotDir,
mediaNames,
}: {
snapshotDir: string;
mediaNames: Array<string>;
}): Promise<ReadonlyArray<string>> {
const filesListPath = join(snapshotDir, 'files');
const writeStream = createWriteStream(filesListPath);
const files: Array<string> = [];
function* generateFrames() {
for (const mediaName of mediaNames) {
const data = Signal.backup.local.FilesFrame.encodeDelimited({
mediaName,
}).finish();
yield data;
files.push(mediaName);
}
}
const frameGenerator = Readable.from(generateFrames());
await pipeline(frameGenerator, writeStream);
return files;
}
export async function readLocalBackupFilesList(
snapshotDir: string
): Promise<ReadonlyArray<string>> {
const filesListPath = join(snapshotDir, 'files');
const readStream = createReadStream(filesListPath);
const delimitedStream = new DelimitedStream();
const mediaNames = new Array<string>();
const parseFilesWritable = new Writable({
objectMode: true,
write(data, _enc, callback) {
try {
const file = Signal.backup.local.FilesFrame.decode(data);
if (file.mediaName) {
mediaNames.push(file.mediaName);
} else {
log.warn(
'ParseFilesListTransform: Active file had empty mediaName, ignoring'
);
}
callback(null);
} catch (error) {
callback(error);
}
},
});
try {
await pipeline(readStream, delimitedStream, parseFilesWritable);
} catch (error) {
try {
readStream.close();
} catch (closeError) {
log.error(
'readLocalBackupFilesList: Error when closing readStream',
Errors.toLogFormat(closeError)
);
}
throw error;
}
readStream.close();
return mediaNames;
}
export type ValidateLocalBackupStructureResultType =
| { success: true; error: undefined; snapshotDir: string | undefined }
| { success: false; error: string; snapshotDir: string | undefined };
export async function validateLocalBackupStructure(
snapshotDir: string
): Promise<ValidateLocalBackupStructureResultType> {
try {
await stat(snapshotDir);
} catch (error) {
return {
success: false,
error: 'Snapshot directory does not exist',
snapshotDir,
};
}
for (const file of ['main', 'metadata', 'files']) {
try {
// eslint-disable-next-line no-await-in-loop
await stat(join(snapshotDir, 'main'));
} catch (error) {
return {
success: false,
error: `Snapshot directory does not contain ${file} file`,
snapshotDir,
};
}
}
return { success: true, error: undefined, snapshotDir };
}