mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-02 08:13:37 +01:00
368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
import { basename, dirname, join } from 'node:path';
|
|
import { readdir, readFile, rm, 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';
|
|
import { getTimestampForFolder } from '../../../util/timestamp.std.js';
|
|
import { isPathInside } from '../../../util/isPathInside.node.js';
|
|
|
|
const log = createLogger('localBackup');
|
|
|
|
const LOCAL_BACKUP_SNAPSHOT_DIR_PREFIX = 'signal-backup-';
|
|
const LOCAL_BACKUP_SNAPSHOT_DIR_PATTERN =
|
|
/^signal-backup-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/;
|
|
|
|
export function getLocalBackupSnapshotDirectory(
|
|
backupsBaseDir: string,
|
|
timestamp: number
|
|
): string {
|
|
return join(
|
|
backupsBaseDir,
|
|
`${LOCAL_BACKUP_SNAPSHOT_DIR_PREFIX}${getTimestampForFolder(timestamp)}`
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
async function getSortedLocalBackupSnapshotDirs({
|
|
backupsBaseDir,
|
|
}: {
|
|
backupsBaseDir: string;
|
|
}): Promise<Array<string>> {
|
|
const entries = await readdir(backupsBaseDir, { withFileTypes: true });
|
|
const snapshotDirs = new Array<string>();
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
if (!entry.name.startsWith(LOCAL_BACKUP_SNAPSHOT_DIR_PREFIX)) {
|
|
continue;
|
|
}
|
|
|
|
if (LOCAL_BACKUP_SNAPSHOT_DIR_PATTERN.test(entry.name)) {
|
|
snapshotDirs.push(join(backupsBaseDir, entry.name));
|
|
}
|
|
}
|
|
|
|
return snapshotDirs.sort().reverse();
|
|
}
|
|
|
|
export async function pruneLocalBackups({
|
|
backupsBaseDir,
|
|
numSnapshotsToKeep,
|
|
}: {
|
|
backupsBaseDir: string;
|
|
numSnapshotsToKeep: number;
|
|
}): Promise<void> {
|
|
const fnLog = log.child('pruneLocalBackups');
|
|
|
|
const snapshotDirs = await getSortedLocalBackupSnapshotDirs({
|
|
backupsBaseDir,
|
|
});
|
|
|
|
const snapshotDirsToKeep = snapshotDirs.slice(0, numSnapshotsToKeep);
|
|
const snapshotDirsToDelete = snapshotDirs.slice(numSnapshotsToKeep);
|
|
|
|
if (snapshotDirsToDelete.length > 0) {
|
|
if (snapshotDirsToDelete.length === 1) {
|
|
fnLog.info('pruning one snapshot');
|
|
} else {
|
|
fnLog.error(`pruning ${snapshotDirsToDelete.length} snapshots`);
|
|
}
|
|
|
|
await Promise.all(
|
|
snapshotDirsToDelete.map(snapshotDir => {
|
|
strictAssert(
|
|
isPathInside(snapshotDir, backupsBaseDir),
|
|
'ensure snapshot dir inside backups dir'
|
|
);
|
|
return rm(snapshotDir, { recursive: true, force: true });
|
|
})
|
|
);
|
|
}
|
|
|
|
const referencedMediaNames = new Set<string>();
|
|
for (const snapshotDir of snapshotDirsToKeep) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const mediaNames = await readLocalBackupFilesList(snapshotDir);
|
|
for (const mediaName of mediaNames) {
|
|
referencedMediaNames.add(mediaName);
|
|
}
|
|
}
|
|
|
|
const allMediaPaths = await getAllPathsInLocalBackupFilesDirectory({
|
|
backupsBaseDir,
|
|
});
|
|
|
|
const mediaPathsToDelete = allMediaPaths.filter(
|
|
mediaPath => !referencedMediaNames.has(basename(mediaPath))
|
|
);
|
|
|
|
if (mediaPathsToDelete.length > 0) {
|
|
fnLog.info(
|
|
`Deleting ${mediaPathsToDelete.length} files no longer referenced`
|
|
);
|
|
const filesDirectory = getLocalBackupFilesDirectory({ backupsBaseDir });
|
|
await Promise.all(
|
|
mediaPathsToDelete.map(mediaPath => {
|
|
strictAssert(
|
|
isPathInside(mediaPath, filesDirectory),
|
|
'ensure mediaPath inside backup files dir'
|
|
);
|
|
return rm(mediaPath, { force: true });
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|