Basic support for local encrypted backups

This commit is contained in:
ayumi-signal 2025-05-12 14:15:11 -07:00 committed by GitHub
parent 2df601b135
commit a2c74c3a8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1782 additions and 176 deletions

View file

@ -6652,6 +6652,26 @@
"messageformat": "Internal", "messageformat": "Internal",
"description": "Button to switch the settings view to control internal configuration" "description": "Button to switch the settings view to control internal configuration"
}, },
"icu:Preferences__internal__local-backups": {
"messageformat": "Local backups",
"description": "Text header for internal local backup tools"
},
"icu:Preferences__internal__export-local-backup": {
"messageformat": "Export…",
"description": "Button to run internal local backup export tool"
},
"icu:Preferences__internal__export-local-backup--description": {
"messageformat": "Export local encrypted backup to a folder and validate it",
"description": "Description of the internal local backup export tool"
},
"icu:Preferences__internal__import-local-backup": {
"messageformat": "Import…",
"description": "Button to run internal local backup import tool"
},
"icu:Preferences__internal__import-local-backup--description": {
"messageformat": "Stage a local encrypted backup for import on link",
"description": "Description of the internal local backup export tool"
},
"icu:Preferences__internal__validate-backup--description": { "icu:Preferences__internal__validate-backup--description": {
"messageformat": "Export encrypted backup to memory and run validation suite on it", "messageformat": "Export encrypted backup to memory and run validation suite on it",
"description": "Description of the internal backup validation tool" "description": "Description of the internal backup validation tool"

View file

@ -3110,31 +3110,50 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
return { canceled: false, filePath: finalFilePath }; return { canceled: false, filePath: finalFilePath };
}); });
ipc.handle('show-save-multi-dialog', async _event => { ipc.handle(
if (!mainWindow) { 'show-open-folder-dialog',
getLogger().warn('show-save-multi-dialog: no main window'); async (
_event,
{ useMainWindow }: { useMainWindow: boolean } = { useMainWindow: false }
) => {
let canceled: boolean;
let selectedDirPaths: ReadonlyArray<string>;
return { canceled: true }; if (useMainWindow) {
} if (!mainWindow) {
const { canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog( getLogger().warn('show-open-folder-dialog: no main window');
mainWindow, return { canceled: true };
{ }
defaultPath: app.getPath('downloads'),
properties: ['openDirectory', 'createDirectory'], ({ canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog(
mainWindow,
{
defaultPath: app.getPath('downloads'),
properties: ['openDirectory', 'createDirectory'],
}
));
} else {
({ canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog({
defaultPath: app.getPath('downloads'),
properties: ['openDirectory', 'createDirectory'],
}));
} }
);
if (canceled || selectedDirPaths.length === 0) { if (canceled || selectedDirPaths.length === 0) {
return { canceled: true }; return { canceled: true };
}
if (selectedDirPaths.length > 1) {
getLogger().warn(
'show-open-folder-dialog: multiple directories selected'
);
return { canceled: true };
}
return { canceled: false, dirPath: selectedDirPaths[0] };
} }
);
if (selectedDirPaths.length > 1) {
getLogger().warn('show-save-multi-dialog: multiple directories selected');
return { canceled: true };
}
return { canceled: false, dirPath: selectedDirPaths[0] };
});
ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => { ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => {
const role = untypedRole as MenuItemConstructorOptions['role']; const role = untypedRole as MenuItemConstructorOptions['role'];

View file

@ -725,11 +725,30 @@ message FilePointer {
message InvalidAttachmentLocator { message InvalidAttachmentLocator {
} }
// References attachments in a local encrypted backup.
// Importers should first attempt to read the file from the local backup,
// and on failure fallback to backup and transit cdn if possible.
message LocalLocator {
string mediaName = 1;
// Separate key used to encrypt this file for the local backup.
// Generally required. Missing field indicates attachment was not
// available locally when the backup was generated, but remote
// backup or transit info was available.
optional bytes localKey = 2;
bytes remoteKey = 3;
bytes remoteDigest = 4;
uint32 size = 5;
optional uint32 backupCdnNumber = 6;
optional string transitCdnKey = 7;
optional uint32 transitCdnNumber = 8;
}
// If unset, importers should consider it to be an InvalidAttachmentLocator without throwing an error. // If unset, importers should consider it to be an InvalidAttachmentLocator without throwing an error.
oneof locator { oneof locator {
BackupLocator backupLocator = 1; BackupLocator backupLocator = 1;
AttachmentLocator attachmentLocator = 2; AttachmentLocator attachmentLocator = 2;
InvalidAttachmentLocator invalidAttachmentLocator = 3; InvalidAttachmentLocator invalidAttachmentLocator = 3;
LocalLocator localLocator = 12;
} }
optional string contentType = 4; optional string contentType = 4;

24
protos/LocalBackup.proto Normal file
View file

@ -0,0 +1,24 @@
syntax = "proto3";
package signal.backup.local;
option java_package = "org.thoughtcrime.securesms.backup.v2.local.proto";
option swift_prefix = "LocalBackupProto_";
message Metadata {
message EncryptedBackupId {
bytes iv = 1; // 12 bytes, randomly generated
bytes encryptedId = 2; // AES-256-CTR, key = local backup metadata key, message = backup ID bytes
// local backup metadata key = hkdf(input: K_B, info: UTF8("20241011_SIGNAL_LOCAL_BACKUP_METADATA_KEY"), length: 32)
// No hash of the ID; if it's decrypted incorrectly, the main backup will fail to decrypt anyway.
}
uint32 version = 1;
EncryptedBackupId backupId = 2; // used to decrypt the backup file knowing only the Account Entropy Pool
}
message FilesFrame {
oneof item {
string mediaName = 1;
}
}

View file

@ -43,6 +43,8 @@ export type CIType = {
) => unknown; ) => unknown;
openSignalRoute(url: string): Promise<void>; openSignalRoute(url: string): Promise<void>;
migrateAllMessages(): Promise<void>; migrateAllMessages(): Promise<void>;
exportLocalBackup(backupsBaseDir: string): Promise<string>;
stageLocalBackupForImport(snapshotDir: string): Promise<void>;
uploadBackup(): Promise<void>; uploadBackup(): Promise<void>;
unlink: () => void; unlink: () => void;
print: (...args: ReadonlyArray<unknown>) => void; print: (...args: ReadonlyArray<unknown>) => void;
@ -193,6 +195,20 @@ export function getCI({
document.body.removeChild(a); document.body.removeChild(a);
} }
async function exportLocalBackup(backupsBaseDir: string): Promise<string> {
const { snapshotDir } =
await backupsService.exportLocalBackup(backupsBaseDir);
return snapshotDir;
}
async function stageLocalBackupForImport(snapshotDir: string): Promise<void> {
const { error } =
await backupsService.stageLocalBackupForImport(snapshotDir);
if (error) {
throw error;
}
}
async function uploadBackup() { async function uploadBackup() {
await backupsService.upload(); await backupsService.upload();
await AttachmentBackupManager.waitForIdle(); await AttachmentBackupManager.waitForIdle();
@ -237,6 +253,8 @@ export function getCI({
waitForEvent, waitForEvent,
openSignalRoute, openSignalRoute,
migrateAllMessages, migrateAllMessages,
exportLocalBackup,
stageLocalBackupForImport,
uploadBackup, uploadBackup,
unlink, unlink,
getPendingEventCount, getPendingEventCount,

View file

@ -214,6 +214,7 @@ import { MessageModel } from './models/messages';
import { waitForEvent } from './shims/events'; import { waitForEvent } from './shims/events';
import { sendSyncRequests } from './textsecure/syncRequests'; import { sendSyncRequests } from './textsecure/syncRequests';
import { handleServerAlerts } from './util/handleServerAlerts'; import { handleServerAlerts } from './util/handleServerAlerts';
import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled';
export function isOverHourIntoPast(timestamp: number): boolean { export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR); return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -1758,7 +1759,7 @@ export async function startApp(): Promise<void> {
hasSentSyncRequests = true; hasSentSyncRequests = true;
} }
// 4. Download (or resume download) of link & sync backup // 4. Download (or resume download) of link & sync backup or local backup
const { wasBackupImported } = await maybeDownloadAndImportBackup(); const { wasBackupImported } = await maybeDownloadAndImportBackup();
log.info(logId, { log.info(logId, {
wasBackupImported, wasBackupImported,
@ -1836,20 +1837,29 @@ export async function startApp(): Promise<void> {
wasBackupImported: boolean; wasBackupImported: boolean;
}> { }> {
const backupDownloadPath = window.storage.get('backupDownloadPath'); const backupDownloadPath = window.storage.get('backupDownloadPath');
if (backupDownloadPath) { const isLocalBackupAvailable =
backupsService.isLocalBackupStaged() && isLocalBackupsEnabled();
if (isLocalBackupAvailable || backupDownloadPath) {
tapToViewMessagesDeletionService.pause(); tapToViewMessagesDeletionService.pause();
// Download backup before enabling request handler and storage service // Download backup before enabling request handler and storage service
try { try {
const { wasBackupImported } = await backupsService.downloadAndImport({ let wasBackupImported = false;
onProgress: (backupStep, currentBytes, totalBytes) => { if (isLocalBackupAvailable) {
window.reduxActions.installer.updateBackupImportProgress({ await backupsService.importLocalBackup();
backupStep, wasBackupImported = true;
currentBytes, } else {
totalBytes, ({ wasBackupImported } = await backupsService.downloadAndImport({
}); onProgress: (backupStep, currentBytes, totalBytes) => {
}, window.reduxActions.installer.updateBackupImportProgress({
}); backupStep,
currentBytes,
totalBytes,
});
},
}));
}
log.info('afterAppStart: backup download attempt completed, resolving'); log.info('afterAppStart: backup download attempt completed, resolving');
backupReady.resolve({ wasBackupImported }); backupReady.resolve({ wasBackupImported });

View file

@ -43,6 +43,28 @@ const availableSpeakers = [
}, },
]; ];
const validateBackupResult = {
totalBytes: 100,
duration: 10000,
stats: {
adHocCalls: 1,
callLinks: 2,
conversations: 3,
chats: 4,
distributionLists: 5,
messages: 6,
notificationProfiles: 2,
skippedMessages: 7,
stickerPacks: 8,
fixedDirectMessages: 9,
},
};
const exportLocalBackupResult = {
...validateBackupResult,
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
};
export default { export default {
title: 'Components/Preferences', title: 'Components/Preferences',
component: Preferences, component: Preferences,
@ -138,6 +160,18 @@ export default {
doDeleteAllData: action('doDeleteAllData'), doDeleteAllData: action('doDeleteAllData'),
doneRendering: action('doneRendering'), doneRendering: action('doneRendering'),
editCustomColor: action('editCustomColor'), editCustomColor: action('editCustomColor'),
exportLocalBackup: async () => {
return {
result: exportLocalBackupResult,
};
},
importLocalBackup: async () => {
return {
success: true,
error: undefined,
snapshotDir: exportLocalBackupResult.snapshotDir,
};
},
makeSyncRequest: action('makeSyncRequest'), makeSyncRequest: action('makeSyncRequest'),
onAudioNotificationsChange: action('onAudioNotificationsChange'), onAudioNotificationsChange: action('onAudioNotificationsChange'),
onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'), onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'),
@ -192,22 +226,7 @@ export default {
), ),
validateBackup: async () => { validateBackup: async () => {
return { return {
result: { result: validateBackupResult,
totalBytes: 100,
duration: 10000,
stats: {
adHocCalls: 1,
callLinks: 2,
conversations: 3,
chats: 4,
distributionLists: 5,
messages: 6,
notificationProfiles: 2,
skippedMessages: 7,
stickerPacks: 8,
fixedDirectMessages: 9,
},
},
}; };
}, },
} satisfies PropsType, } satisfies PropsType,

View file

@ -74,6 +74,7 @@ import {
import { PreferencesBackups } from './PreferencesBackups'; import { PreferencesBackups } from './PreferencesBackups';
import { PreferencesInternal } from './PreferencesInternal'; import { PreferencesInternal } from './PreferencesInternal';
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider'; import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
type CheckboxChangeHandlerType = (value: boolean) => unknown; type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown; type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
@ -157,9 +158,11 @@ type PropsFunctionType = {
doDeleteAllData: () => unknown; doDeleteAllData: () => unknown;
doneRendering: () => unknown; doneRendering: () => unknown;
editCustomColor: (colorId: string, color: CustomColorType) => unknown; editCustomColor: (colorId: string, color: CustomColorType) => unknown;
exportLocalBackup: () => Promise<BackupValidationResultType>;
getConversationsWithCustomColor: ( getConversationsWithCustomColor: (
colorId: string colorId: string
) => Promise<Array<ConversationType>>; ) => Promise<Array<ConversationType>>;
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
makeSyncRequest: () => unknown; makeSyncRequest: () => unknown;
refreshCloudBackupStatus: () => void; refreshCloudBackupStatus: () => void;
refreshBackupSubscriptionStatus: () => void; refreshBackupSubscriptionStatus: () => void;
@ -286,6 +289,7 @@ export function Preferences({
doneRendering, doneRendering,
editCustomColor, editCustomColor,
emojiSkinToneDefault, emojiSkinToneDefault,
exportLocalBackup,
getConversationsWithCustomColor, getConversationsWithCustomColor,
hasAudioNotifications, hasAudioNotifications,
hasAutoConvertEmoji, hasAutoConvertEmoji,
@ -311,6 +315,7 @@ export function Preferences({
hasTextFormatting, hasTextFormatting,
hasTypingIndicators, hasTypingIndicators,
i18n, i18n,
importLocalBackup,
initialPage = Page.General, initialPage = Page.General,
initialSpellCheckSetting, initialSpellCheckSetting,
isAutoDownloadUpdatesSupported, isAutoDownloadUpdatesSupported,
@ -1736,7 +1741,12 @@ export function Preferences({
); );
} else if (page === Page.Internal) { } else if (page === Page.Internal) {
settings = ( settings = (
<PreferencesInternal i18n={i18n} validateBackup={validateBackup} /> <PreferencesInternal
i18n={i18n}
exportLocalBackup={exportLocalBackup}
importLocalBackup={importLocalBackup}
validateBackup={validateBackup}
/>
); );
} }

View file

@ -7,17 +7,30 @@ import { toLogFormat } from '../types/errors';
import { formatFileSize } from '../util/formatFileSize'; import { formatFileSize } from '../util/formatFileSize';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import type { ValidationResultType as BackupValidationResultType } from '../services/backups'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups';
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
import { SettingsRow, SettingsControl } from './PreferencesUtil'; import { SettingsRow, SettingsControl } from './PreferencesUtil';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner'; import { Spinner } from './Spinner';
export function PreferencesInternal({ export function PreferencesInternal({
i18n, i18n,
exportLocalBackup: doExportLocalBackup,
importLocalBackup: doImportLocalBackup,
validateBackup: doValidateBackup, validateBackup: doValidateBackup,
}: { }: {
i18n: LocalizerType; i18n: LocalizerType;
exportLocalBackup: () => Promise<BackupValidationResultType>;
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
validateBackup: () => Promise<BackupValidationResultType>; validateBackup: () => Promise<BackupValidationResultType>;
}): JSX.Element { }): JSX.Element {
const [isExportPending, setIsExportPending] = useState(false);
const [exportResult, setExportResult] = useState<
BackupValidationResultType | undefined
>();
const [importResult, setImportResult] = useState<
ValidateLocalBackupStructureResultType | undefined
>();
const [isValidationPending, setIsValidationPending] = useState(false); const [isValidationPending, setIsValidationPending] = useState(false);
const [validationResult, setValidationResult] = useState< const [validationResult, setValidationResult] = useState<
BackupValidationResultType | undefined BackupValidationResultType | undefined
@ -35,34 +48,110 @@ export function PreferencesInternal({
} }
}, [doValidateBackup]); }, [doValidateBackup]);
let validationElem: JSX.Element | undefined; const renderValidationResult = useCallback(
if (validationResult != null) { (
if ('result' in validationResult) { backupResult: BackupValidationResultType | undefined
const { ): JSX.Element | undefined => {
result: { totalBytes, stats, duration }, if (backupResult == null) {
} = validationResult; return;
}
validationElem = ( if ('result' in backupResult) {
<div className="Preferences--internal--validate-backup--result"> const {
<p>File size: {formatFileSize(totalBytes)}</p> result: { totalBytes, stats, duration },
<p>Duration: {Math.round(duration / SECOND)}s</p> } = backupResult;
<pre>
<code>{JSON.stringify(stats, null, 2)}</code>
</pre>
</div>
);
} else {
const { error } = validationResult;
validationElem = ( let snapshotDirEl: JSX.Element | undefined;
if ('snapshotDir' in backupResult.result) {
snapshotDirEl = (
<p>
Backup path:
<pre>
<code>{backupResult.result.snapshotDir}</code>
</pre>
</p>
);
}
return (
<div className="Preferences--internal--validate-backup--result">
{snapshotDirEl}
<p>Main file size: {formatFileSize(totalBytes)}</p>
<p>Duration: {Math.round(duration / SECOND)}s</p>
<pre>
<code>{JSON.stringify(stats, null, 2)}</code>
</pre>
</div>
);
}
const { error } = backupResult;
return (
<div className="Preferences--internal--validate-backup--error"> <div className="Preferences--internal--validate-backup--error">
<pre> <pre>
<code>{error}</code> <code>{error}</code>
</pre> </pre>
</div> </div>
); );
},
[]
);
const exportLocalBackup = useCallback(async () => {
setIsExportPending(true);
setExportResult(undefined);
try {
setExportResult(await doExportLocalBackup());
} catch (error) {
setExportResult({ error: toLogFormat(error) });
} finally {
setIsExportPending(false);
} }
} }, [doExportLocalBackup]);
const importLocalBackup = useCallback(async () => {
setImportResult(undefined);
try {
setImportResult(await doImportLocalBackup());
} catch (error) {
setImportResult({
success: false,
error: toLogFormat(error),
snapshotDir: undefined,
});
}
}, [doImportLocalBackup]);
const renderImportResult = useCallback(
(
didImportResult: ValidateLocalBackupStructureResultType | undefined
): JSX.Element | undefined => {
if (didImportResult == null) {
return;
}
const { success, error, snapshotDir } = didImportResult;
if (success) {
return (
<div className="Preferences--internal--validate-backup--result">
<pre>
<code>{`Staged: ${snapshotDir}\n\nPlease link to finish import.`}</code>
</pre>
</div>
);
}
return (
<div className="Preferences--internal--validate-backup--error">
<pre>
<code>{`Failed: ${error}`}</code>
</pre>
</div>
);
},
[]
);
return ( return (
<> <>
@ -93,7 +182,49 @@ export function PreferencesInternal({
} }
/> />
{validationElem} {renderValidationResult(validationResult)}
</SettingsRow>
<SettingsRow
className="Preferences--internal--backups"
title={i18n('icu:Preferences__internal__local-backups')}
>
<SettingsControl
left={i18n(
'icu:Preferences__internal__export-local-backup--description'
)}
right={
<Button
variant={ButtonVariant.Secondary}
onClick={exportLocalBackup}
disabled={isExportPending}
>
{isExportPending ? (
<Spinner size="22px" svgSize="small" />
) : (
i18n('icu:Preferences__internal__export-local-backup')
)}
</Button>
}
/>
{renderValidationResult(exportResult)}
<SettingsControl
left={i18n(
'icu:Preferences__internal__import-local-backup--description'
)}
right={
<Button
variant={ButtonVariant.Secondary}
onClick={importLocalBackup}
>
{i18n('icu:Preferences__internal__import-local-backup')}
</Button>
}
/>
{renderImportResult(importResult)}
</SettingsRow> </SettingsRow>
</> </>
); );

View file

@ -24,6 +24,7 @@ import {
AttachmentVariant, AttachmentVariant,
AttachmentPermanentlyUndownloadableError, AttachmentPermanentlyUndownloadableError,
mightBeOnBackupTier, mightBeOnBackupTier,
mightBeInLocalBackup,
} from '../types/Attachment'; } from '../types/Attachment';
import { type ReadonlyMessageAttributesType } from '../model-types.d'; import { type ReadonlyMessageAttributesType } from '../model-types.d';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
@ -285,9 +286,10 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
// try to download from the transit tier (or it's an invalid attachment, etc.). We // try to download from the transit tier (or it's an invalid attachment, etc.). We
// may need to extend the attachment_downloads table in the future to better // may need to extend the attachment_downloads table in the future to better
// differentiate source vs. location. // differentiate source vs. location.
source: mightBeOnBackupTier(attachment) source:
? source mightBeOnBackupTier(attachment) || mightBeInLocalBackup(attachment)
: AttachmentDownloadSource.STANDARD, ? source
: AttachmentDownloadSource.STANDARD,
}); });
if (!parseResult.success) { if (!parseResult.success) {
@ -462,7 +464,10 @@ async function runDownloadAttachmentJob({
}; };
} }
if (mightBeOnBackupTier(job.attachment)) { if (
mightBeOnBackupTier(job.attachment) ||
mightBeInLocalBackup(job.attachment)
) {
const currentDownloadedSize = const currentDownloadedSize =
window.storage.get('backupMediaDownloadCompletedBytes') ?? 0; window.storage.get('backupMediaDownloadCompletedBytes') ?? 0;
drop( drop(
@ -615,7 +620,8 @@ export async function runDownloadAttachmentJobInner({
isForCurrentlyVisibleMessage && isForCurrentlyVisibleMessage &&
mightHaveThumbnailOnBackupTier(job.attachment) && mightHaveThumbnailOnBackupTier(job.attachment) &&
// TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job // TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job
!job.attachment.thumbnailFromBackup; !job.attachment.thumbnailFromBackup &&
!mightBeInLocalBackup(attachment);
if (preferBackupThumbnail) { if (preferBackupThumbnail) {
logId += '.preferringBackupThumbnail'; logId += '.preferringBackupThumbnail';
@ -811,7 +817,9 @@ async function downloadBackupThumbnail({
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
abortSignal: AbortSignal; abortSignal: AbortSignal;
dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; dependencies: {
downloadAttachment: typeof downloadAttachmentUtil;
};
}): Promise<AttachmentType> { }): Promise<AttachmentType> {
const downloadedThumbnail = await dependencies.downloadAttachment({ const downloadedThumbnail = await dependencies.downloadAttachment({
attachment, attachment,

View file

@ -0,0 +1,279 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { existsSync } from 'node:fs';
import { PassThrough } from 'node:stream';
import { constants as FS_CONSTANTS, copyFile, mkdir } from 'fs/promises';
import * as durations from '../util/durations';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { redactGenericText } from '../util/privacy';
import {
JobManager,
type JobManagerParamsType,
type JobManagerJobResultType,
} from './JobManager';
import { type BackupsService, backupsService } from '../services/backups';
import { decryptAttachmentV2ToSink } from '../AttachmentCrypto';
import {
type AttachmentLocalBackupJobType,
type CoreAttachmentLocalBackupJobType,
} from '../types/AttachmentBackup';
import { isInCall as isInCallSelector } from '../state/selectors/calling';
import { encryptAndUploadAttachment } from '../util/uploadAttachment';
import type { WebAPIType } from '../textsecure/WebAPI';
import type { LocallySavedAttachment } from '../types/Attachment';
import {
getLocalBackupDirectoryForMediaName,
getLocalBackupPathForMediaName,
} from '../services/backups/util/localBackup';
const MAX_CONCURRENT_JOBS = 3;
const RETRY_CONFIG = {
maxAttempts: 3,
backoffConfig: {
// 1 minute, 5 minutes, 25 minutes, every hour
multiplier: 3,
firstBackoffs: [10 * durations.SECOND],
maxBackoffTime: durations.MINUTE,
},
};
export class AttachmentLocalBackupManager extends JobManager<CoreAttachmentLocalBackupJobType> {
static #instance: AttachmentLocalBackupManager | undefined;
readonly #jobsByMediaName = new Map<string, AttachmentLocalBackupJobType>();
static defaultParams: JobManagerParamsType<CoreAttachmentLocalBackupJobType> =
{
markAllJobsInactive: AttachmentLocalBackupManager.markAllJobsInactive,
saveJob: AttachmentLocalBackupManager.saveJob,
removeJob: AttachmentLocalBackupManager.removeJob,
getNextJobs: AttachmentLocalBackupManager.getNextJobs,
runJob: runAttachmentBackupJob,
shouldHoldOffOnStartingQueuedJobs: () => {
const reduxState = window.reduxStore?.getState();
if (reduxState) {
return isInCallSelector(reduxState);
}
return false;
},
getJobId,
getJobIdForLogging,
getRetryConfig: () => RETRY_CONFIG,
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
};
override logPrefix = 'AttachmentLocalBackupManager';
static get instance(): AttachmentLocalBackupManager {
if (!AttachmentLocalBackupManager.#instance) {
AttachmentLocalBackupManager.#instance = new AttachmentLocalBackupManager(
AttachmentLocalBackupManager.defaultParams
);
}
return AttachmentLocalBackupManager.#instance;
}
static get jobs(): Map<string, AttachmentLocalBackupJobType> {
return AttachmentLocalBackupManager.instance.#jobsByMediaName;
}
static async start(): Promise<void> {
log.info('AttachmentLocalBackupManager/starting');
await AttachmentLocalBackupManager.instance.start();
}
static async stop(): Promise<void> {
log.info('AttachmentLocalBackupManager/stopping');
return AttachmentLocalBackupManager.#instance?.stop();
}
static async addJob(newJob: CoreAttachmentLocalBackupJobType): Promise<void> {
return AttachmentLocalBackupManager.instance.addJob(newJob);
}
static async waitForIdle(): Promise<void> {
return AttachmentLocalBackupManager.instance.waitForIdle();
}
static async markAllJobsInactive(): Promise<void> {
for (const [mediaName, job] of AttachmentLocalBackupManager.jobs) {
AttachmentLocalBackupManager.jobs.set(mediaName, {
...job,
active: false,
});
}
}
static async saveJob(job: AttachmentLocalBackupJobType): Promise<void> {
AttachmentLocalBackupManager.jobs.set(job.mediaName, job);
}
static async removeJob(
job: Pick<AttachmentLocalBackupJobType, 'mediaName'>
): Promise<void> {
AttachmentLocalBackupManager.jobs.delete(job.mediaName);
}
static clearAllJobs(): void {
AttachmentLocalBackupManager.jobs.clear();
}
static async getNextJobs({
limit,
timestamp,
}: {
limit: number;
timestamp: number;
}): Promise<Array<AttachmentLocalBackupJobType>> {
let countRemaining = limit;
const nextJobs: Array<AttachmentLocalBackupJobType> = [];
for (const job of AttachmentLocalBackupManager.jobs.values()) {
if (job.active || (job.retryAfter && job.retryAfter > timestamp)) {
continue;
}
nextJobs.push(job);
countRemaining -= 1;
if (countRemaining <= 0) {
break;
}
}
return nextJobs;
}
}
function getJobId(job: CoreAttachmentLocalBackupJobType): string {
return job.mediaName;
}
function getJobIdForLogging(job: CoreAttachmentLocalBackupJobType): string {
return `${redactGenericText(job.mediaName)}.${job.type}`;
}
/**
* Backup-specific methods
*/
class AttachmentPermanentlyMissingError extends Error {}
type RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath: typeof window.Signal.Migrations.getAbsoluteAttachmentPath;
backupMediaBatch?: WebAPIType['backupMediaBatch'];
backupsService: BackupsService;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
};
export async function runAttachmentBackupJob(
job: AttachmentLocalBackupJobType,
_options: {
isLastAttempt: boolean;
abortSignal: AbortSignal;
},
dependencies: RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
backupsService,
backupMediaBatch: window.textsecure.server?.backupMediaBatch,
encryptAndUploadAttachment,
decryptAttachmentV2ToSink,
}
): Promise<JobManagerJobResultType<CoreAttachmentLocalBackupJobType>> {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentLocalBackupManager/runAttachmentBackupJob/${jobIdForLogging}`;
try {
await runAttachmentBackupJobInner(job, dependencies);
return { status: 'finished' };
} catch (error) {
log.error(
`${logId}: Failed to backup attachment, attempt ${job.attempts}`,
Errors.toLogFormat(error)
);
if (error instanceof AttachmentPermanentlyMissingError) {
log.error(`${logId}: Attachment unable to be found, giving up on job`);
return { status: 'finished' };
}
return { status: 'retry' };
}
}
async function runAttachmentBackupJobInner(
job: AttachmentLocalBackupJobType,
dependencies: RunAttachmentBackupJobDependenciesType
): Promise<void> {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentLocalBackupManager.runAttachmentBackupJobInner(${jobIdForLogging})`;
log.info(`${logId}: starting`);
const { backupsBaseDir, mediaName } = job;
const { contentType, digest, iv, keys, localKey, path, size } = job.data;
if (!path) {
throw new AttachmentPermanentlyMissingError('No path property');
}
const absolutePath = dependencies.getAbsoluteAttachmentPath(path);
if (!existsSync(absolutePath)) {
throw new AttachmentPermanentlyMissingError('No file at provided path');
}
if (!localKey) {
throw new Error('No localKey property, required for test decryption');
}
const localBackupFileDir = getLocalBackupDirectoryForMediaName({
backupsBaseDir,
mediaName,
});
await mkdir(localBackupFileDir, { recursive: true });
const localBackupFilePath = getLocalBackupPathForMediaName({
backupsBaseDir,
mediaName,
});
const attachment: LocallySavedAttachment = {
path,
iv,
key: keys,
localKey,
digest,
contentType,
size,
};
// TODO: Add check in local FS to prevent double backup
// File is already encrypted with localKey, so we just have to copy it to the backup dir
const attachmentPath = window.Signal.Migrations.getAbsoluteAttachmentPath(
attachment.path
);
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy)
await copyFile(
attachmentPath,
localBackupFilePath,
FS_CONSTANTS.COPYFILE_FICLONE
);
// TODO: Optimize this check -- it can be expensive to test decrypt on every export
log.info(`${logId}: Verifying file restored from local backup`);
const sink = new PassThrough();
sink.resume();
await decryptAttachmentV2ToSink(
{
ciphertextPath: localBackupFilePath,
idForLogging: 'attachments/readAndDecryptDataFromDisk',
keysBase64: localKey,
size,
type: 'local',
},
sink
);
}

View file

@ -68,6 +68,8 @@ export class SettingsChannel extends EventEmitter {
this.#installCallback('syncRequest'); this.#installCallback('syncRequest');
this.#installCallback('setEmojiSkinToneDefault'); this.#installCallback('setEmojiSkinToneDefault');
this.#installCallback('getEmojiSkinToneDefault'); this.#installCallback('getEmojiSkinToneDefault');
this.#installCallback('exportLocalBackup');
this.#installCallback('importLocalBackup');
this.#installCallback('validateBackup'); this.#installCallback('validateBackup');
// Backups // Backups

View file

@ -6,6 +6,10 @@ import type { ConversationColorType } from '../../types/Colors';
export const BACKUP_VERSION = 1; export const BACKUP_VERSION = 1;
export const LOCAL_BACKUP_VERSION = 1;
export const LOCAL_BACKUP_BACKUP_ID_IV_LENGTH = 16;
const { WallpaperPreset } = Backups.ChatStyle; const { WallpaperPreset } = Backups.ChatStyle;
// See https://github.com/signalapp/Signal-Android-Private/blob/4a41e9f9a1ed0aba7cae0e0dc4dbcac50fddc469/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt#L32 // See https://github.com/signalapp/Signal-Android-Private/blob/4a41e9f9a1ed0aba7cae0e0dc4dbcac50fddc469/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt#L32

View file

@ -125,3 +125,12 @@ export function deriveBackupThumbnailTransitKeyMaterial(
), ),
}; };
} }
export function getBackupId(): Uint8Array {
const aci = window.storage.user.getCheckedAci();
return getBackupKey().deriveBackupId(toAciObject(aci));
}
export function getLocalBackupMetadataKey(): Uint8Array {
return getBackupKey().deriveLocalBackupMetadataKey();
}

View file

@ -4,6 +4,7 @@
import Long from 'long'; import Long from 'long';
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client';
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { dirname } from 'path';
import pMap from 'p-map'; import pMap from 'p-map';
import pTimeout from 'p-timeout'; import pTimeout from 'p-timeout';
import { Readable } from 'stream'; import { Readable } from 'stream';
@ -125,11 +126,13 @@ import {
} from '../../types/Attachment'; } from '../../types/Attachment';
import { import {
getFilePointerForAttachment, getFilePointerForAttachment,
getLocalBackupFilePointerForAttachment,
maybeGetBackupJobForAttachmentAndFilePointer, maybeGetBackupJobForAttachmentAndFilePointer,
} from './util/filePointers'; } from './util/filePointers';
import { getBackupMediaRootKey } from './crypto'; import { getBackupMediaRootKey } from './crypto';
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup'; import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager'; import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager';
import { getBackupCdnInfo } from './util/mediaId'; import { getBackupCdnInfo } from './util/mediaId';
import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
@ -177,6 +180,7 @@ type ToChatItemOptionsType = Readonly<{
aboutMe: AboutMe; aboutMe: AboutMe;
callHistoryByCallId: Record<string, CallHistoryDetails>; callHistoryByCallId: Record<string, CallHistoryDetails>;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
}>; }>;
type NonBubbleOptionsType = Pick< type NonBubbleOptionsType = Pick<
@ -227,6 +231,7 @@ export class BackupExportStream extends Readable {
readonly #serviceIdToRecipientId = new Map<string, number>(); readonly #serviceIdToRecipientId = new Map<string, number>();
readonly #e164ToRecipientId = new Map<string, number>(); readonly #e164ToRecipientId = new Map<string, number>();
readonly #roomIdToRecipientId = new Map<string, number>(); readonly #roomIdToRecipientId = new Map<string, number>();
readonly #mediaNamesToFilePointers = new Map<string, Backups.FilePointer>();
readonly #stats: StatsType = { readonly #stats: StatsType = {
adHocCalls: 0, adHocCalls: 0,
callLinks: 0, callLinks: 0,
@ -253,43 +258,82 @@ export class BackupExportStream extends Readable {
super(); super();
} }
public run(backupLevel: BackupLevel): void { public run(
backupLevel: BackupLevel,
localBackupSnapshotDir: string | undefined = undefined
): void {
const localBackupsBaseDir = localBackupSnapshotDir
? dirname(localBackupSnapshotDir)
: undefined;
const isLocalBackup = localBackupsBaseDir != null;
drop( drop(
(async () => { (async () => {
log.info('BackupExportStream: starting...'); log.info('BackupExportStream: starting...');
drop(AttachmentBackupManager.stop()); drop(AttachmentBackupManager.stop());
drop(AttachmentLocalBackupManager.stop());
log.info('BackupExportStream: message migration starting...'); log.info('BackupExportStream: message migration starting...');
await migrateAllMessages(); await migrateAllMessages();
await pauseWriteAccess(); await pauseWriteAccess();
try { try {
await this.#unsafeRun(backupLevel); await this.#unsafeRun(backupLevel, isLocalBackup);
} catch (error) { } catch (error) {
this.emit('error', error); this.emit('error', error);
} finally { } finally {
await resumeWriteAccess(); await resumeWriteAccess();
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction if (isLocalBackup) {
await DataWriter.clearAllAttachmentBackupJobs(); log.info(
if (this.backupType !== BackupType.TestOnlyPlaintext) { `BackupExportStream: Adding ${this.#attachmentBackupJobs.length} jobs for AttachmentLocalBackupManager`
await Promise.all(
this.#attachmentBackupJobs.map(job =>
AttachmentBackupManager.addJobAndMaybeThumbnailJob(job)
)
); );
drop(AttachmentBackupManager.start()); AttachmentLocalBackupManager.clearAllJobs();
await Promise.all(
this.#attachmentBackupJobs.map(job => {
if (job.type === 'thumbnail') {
log.error(
"BackupExportStream: Can't backup thumbnails to local backup, skipping"
);
return Promise.resolve();
}
return AttachmentLocalBackupManager.addJob({
...job,
backupsBaseDir: localBackupsBaseDir,
});
})
);
drop(AttachmentLocalBackupManager.start());
} else {
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
await DataWriter.clearAllAttachmentBackupJobs();
if (this.backupType !== BackupType.TestOnlyPlaintext) {
await Promise.all(
this.#attachmentBackupJobs.map(job =>
AttachmentBackupManager.addJobAndMaybeThumbnailJob(job)
)
);
drop(AttachmentBackupManager.start());
}
} }
log.info('BackupExportStream: finished'); log.info('BackupExportStream: finished');
} }
})() })()
); );
} }
public getMediaNamesIterator(): MapIterator<string> {
return this.#mediaNamesToFilePointers.keys();
}
public getStats(): Readonly<StatsType> { public getStats(): Readonly<StatsType> {
return this.#stats; return this.#stats;
} }
async #unsafeRun(backupLevel: BackupLevel): Promise<void> { async #unsafeRun(
backupLevel: BackupLevel,
isLocalBackup: boolean
): Promise<void> {
this.#ourConversation = this.#ourConversation =
window.ConversationController.getOurConversationOrThrow().attributes; window.ConversationController.getOurConversationOrThrow().attributes;
this.push( this.push(
@ -663,6 +707,7 @@ export class BackupExportStream extends Readable {
aboutMe, aboutMe,
callHistoryByCallId, callHistoryByCallId,
backupLevel, backupLevel,
isLocalBackup,
}), }),
{ concurrency: MAX_CONCURRENCY } { concurrency: MAX_CONCURRENCY }
); );
@ -1093,7 +1138,12 @@ export class BackupExportStream extends Readable {
async #toChatItem( async #toChatItem(
message: MessageAttributesType, message: MessageAttributesType,
{ aboutMe, callHistoryByCallId, backupLevel }: ToChatItemOptionsType {
aboutMe,
callHistoryByCallId,
backupLevel,
isLocalBackup,
}: ToChatItemOptionsType
): Promise<Backups.IChatItem | undefined> { ): Promise<Backups.IChatItem | undefined> {
const conversation = window.ConversationController.get( const conversation = window.ConversationController.get(
message.conversationId message.conversationId
@ -1253,6 +1303,7 @@ export class BackupExportStream extends Readable {
result.viewOnceMessage = await this.#toViewOnceMessage({ result.viewOnceMessage = await this.#toViewOnceMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}); });
} else if (message.deletedForEveryone) { } else if (message.deletedForEveryone) {
result.remoteDeletedMessage = {}; result.remoteDeletedMessage = {};
@ -1314,6 +1365,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: contactDetails.avatar.avatar, attachment: contactDetails.avatar.avatar,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
}) })
: undefined, : undefined,
@ -1335,6 +1387,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: sticker.data, attachment: sticker.data,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
}) })
: undefined; : undefined;
@ -1378,23 +1431,27 @@ export class BackupExportStream extends Readable {
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({ result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}); });
result.revisions = await this.#toChatItemRevisions( result.revisions = await this.#toChatItemRevisions(
result, result,
message, message,
backupLevel backupLevel,
isLocalBackup
); );
} else { } else {
result.standardMessage = await this.#toStandardMessage({ result.standardMessage = await this.#toStandardMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}); });
result.revisions = await this.#toChatItemRevisions( result.revisions = await this.#toChatItemRevisions(
result, result,
message, message,
backupLevel backupLevel,
isLocalBackup
); );
} }
@ -2297,9 +2354,11 @@ export class BackupExportStream extends Readable {
async #toQuote({ async #toQuote({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}: { }: {
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>; message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
}): Promise<Backups.IQuote | null> { }): Promise<Backups.IQuote | null> {
const { quote } = message; const { quote } = message;
if (!quote) { if (!quote) {
@ -2359,6 +2418,7 @@ export class BackupExportStream extends Readable {
attachment: attachment.thumbnail, attachment: attachment.thumbnail,
backupLevel, backupLevel,
message, message,
isLocalBackup,
}) })
: undefined, : undefined,
}; };
@ -2417,16 +2477,19 @@ export class BackupExportStream extends Readable {
attachment, attachment,
backupLevel, backupLevel,
message, message,
isLocalBackup,
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
backupLevel: BackupLevel; backupLevel: BackupLevel;
message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>; message: Pick<MessageAttributesType, 'quote' | 'received_at' | 'body'>;
isLocalBackup: boolean;
}): Promise<Backups.MessageAttachment> { }): Promise<Backups.MessageAttachment> {
const { clientUuid } = attachment; const { clientUuid } = attachment;
const filePointer = await this.#processAttachment({ const filePointer = await this.#processAttachment({
attachment, attachment,
backupLevel, backupLevel,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
isLocalBackup,
}); });
return new Backups.MessageAttachment({ return new Backups.MessageAttachment({
@ -2440,18 +2503,51 @@ export class BackupExportStream extends Readable {
async #processAttachment({ async #processAttachment({
attachment, attachment,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt, messageReceivedAt,
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
messageReceivedAt: number; messageReceivedAt: number;
}): Promise<Backups.FilePointer> { }): Promise<Backups.FilePointer> {
const { filePointer, updatedAttachment } = // We need to always get updatedAttachment in case the attachment wasn't reencryptable
await getFilePointerForAttachment({ // to the original digest. In that case mediaName will be based on updatedAttachment.
attachment, const { filePointer, updatedAttachment } = isLocalBackup
backupLevel, ? await getLocalBackupFilePointerForAttachment({
getBackupCdnInfo, attachment,
}); backupLevel,
getBackupCdnInfo,
})
: await getFilePointerForAttachment({
attachment,
backupLevel,
getBackupCdnInfo,
});
if (isLocalBackup && filePointer.localLocator) {
// Duplicate attachment check. Local backups can only contain 1 file per mediaName,
// so if we see a duplicate mediaName then we must reuse the previous FilePointer.
const { mediaName } = filePointer.localLocator;
strictAssert(
mediaName,
'FilePointer.LocalLocator must contain mediaName'
);
const existingFilePointer = this.#mediaNamesToFilePointers.get(mediaName);
if (existingFilePointer) {
strictAssert(
existingFilePointer.localLocator,
'Local backup existing mediaName FilePointer must contain LocalLocator'
);
strictAssert(
existingFilePointer.localLocator.size === attachment.size,
'Local backup existing mediaName FilePointer size must match attachment'
);
return existingFilePointer;
}
this.#mediaNamesToFilePointers.set(mediaName, filePointer);
}
if (updatedAttachment) { if (updatedAttachment) {
// TODO (DESKTOP-6688): ensure that we update the message/attachment in DB with the // TODO (DESKTOP-6688): ensure that we update the message/attachment in DB with the
@ -2639,6 +2735,7 @@ export class BackupExportStream extends Readable {
async #toStandardMessage({ async #toStandardMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}: { }: {
message: Pick< message: Pick<
MessageAttributesType, MessageAttributesType,
@ -2652,11 +2749,13 @@ export class BackupExportStream extends Readable {
| 'received_at' | 'received_at'
>; >;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
}): Promise<Backups.IStandardMessage> { }): Promise<Backups.IStandardMessage> {
return { return {
quote: await this.#toQuote({ quote: await this.#toQuote({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}), }),
attachments: message.attachments?.length attachments: message.attachments?.length
? await Promise.all( ? await Promise.all(
@ -2665,6 +2764,7 @@ export class BackupExportStream extends Readable {
attachment, attachment,
backupLevel, backupLevel,
message, message,
isLocalBackup,
}); });
}) })
) )
@ -2673,6 +2773,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: message.bodyAttachment, attachment: message.bodyAttachment,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
}) })
: undefined, : undefined,
@ -2697,6 +2798,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: preview.image, attachment: preview.image,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
}) })
: undefined, : undefined,
@ -2711,6 +2813,7 @@ export class BackupExportStream extends Readable {
async #toDirectStoryReplyMessage({ async #toDirectStoryReplyMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}: { }: {
message: Pick< message: Pick<
MessageAttributesType, MessageAttributesType,
@ -2722,6 +2825,7 @@ export class BackupExportStream extends Readable {
| 'reactions' | 'reactions'
>; >;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
}): Promise<Backups.IDirectStoryReplyMessage> { }): Promise<Backups.IDirectStoryReplyMessage> {
const result = new Backups.DirectStoryReplyMessage({ const result = new Backups.DirectStoryReplyMessage({
reactions: this.#getMessageReactions(message), reactions: this.#getMessageReactions(message),
@ -2735,6 +2839,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({ ? await this.#processAttachment({
attachment: message.bodyAttachment, attachment: message.bodyAttachment,
backupLevel, backupLevel,
isLocalBackup,
messageReceivedAt: message.received_at, messageReceivedAt: message.received_at,
}) })
: undefined, : undefined,
@ -2755,12 +2860,14 @@ export class BackupExportStream extends Readable {
async #toViewOnceMessage({ async #toViewOnceMessage({
message, message,
backupLevel, backupLevel,
isLocalBackup,
}: { }: {
message: Pick< message: Pick<
MessageAttributesType, MessageAttributesType,
'attachments' | 'received_at' | 'reactions' 'attachments' | 'received_at' | 'reactions'
>; >;
backupLevel: BackupLevel; backupLevel: BackupLevel;
isLocalBackup: boolean;
}): Promise<Backups.IViewOnceMessage> { }): Promise<Backups.IViewOnceMessage> {
const attachment = message.attachments?.at(0); const attachment = message.attachments?.at(0);
return { return {
@ -2771,6 +2878,7 @@ export class BackupExportStream extends Readable {
attachment, attachment,
backupLevel, backupLevel,
message, message,
isLocalBackup,
}), }),
reactions: this.#getMessageReactions(message), reactions: this.#getMessageReactions(message),
}; };
@ -2779,7 +2887,8 @@ export class BackupExportStream extends Readable {
async #toChatItemRevisions( async #toChatItemRevisions(
parent: Backups.IChatItem, parent: Backups.IChatItem,
message: MessageAttributesType, message: MessageAttributesType,
backupLevel: BackupLevel backupLevel: BackupLevel,
isLocalBackup: boolean
): Promise<Array<Backups.IChatItem> | undefined> { ): Promise<Array<Backups.IChatItem> | undefined> {
const { editHistory } = message; const { editHistory } = message;
if (editHistory == null) { if (editHistory == null) {
@ -2818,11 +2927,13 @@ export class BackupExportStream extends Readable {
await this.#toDirectStoryReplyMessage({ await this.#toDirectStoryReplyMessage({
message: history, message: history,
backupLevel, backupLevel,
isLocalBackup,
}); });
} else { } else {
result.standardMessage = await this.#toStandardMessage({ result.standardMessage = await this.#toStandardMessage({
message: history, message: history,
backupLevel, backupLevel,
isLocalBackup,
}); });
} }
return result; return result;

View file

@ -244,18 +244,22 @@ export class BackupImportStream extends Writable {
#pendingGroupAvatars = new Map<string, string>(); #pendingGroupAvatars = new Map<string, string>();
#frameErrorCount: number = 0; #frameErrorCount: number = 0;
private constructor(private readonly backupType: BackupType) { private constructor(
private readonly backupType: BackupType,
private readonly localBackupSnapshotDir: string | undefined
) {
super({ objectMode: true }); super({ objectMode: true });
} }
public static async create( public static async create(
backupType = BackupType.Ciphertext backupType = BackupType.Ciphertext,
localBackupSnapshotDir: string | undefined = undefined
): Promise<BackupImportStream> { ): Promise<BackupImportStream> {
await AttachmentDownloadManager.stop(); await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs(); await DataWriter.removeAllBackupAttachmentDownloadJobs();
await resetBackupMediaDownloadProgress(); await resetBackupMediaDownloadProgress();
return new BackupImportStream(backupType); return new BackupImportStream(backupType, localBackupSnapshotDir);
} }
override async _write( override async _write(
@ -1854,11 +1858,19 @@ export class BackupImportStream extends Writable {
bodyRanges: this.#fromBodyRanges(data.text), bodyRanges: this.#fromBodyRanges(data.text),
})), })),
bodyAttachment: data.longText bodyAttachment: data.longText
? convertFilePointerToAttachment(data.longText) ? convertFilePointerToAttachment(
data.longText,
this.#getFilePointerOptions()
)
: undefined, : undefined,
attachments: data.attachments?.length attachments: data.attachments?.length
? data.attachments ? data.attachments
.map(convertBackupMessageAttachmentToAttachment) .map(attachment =>
convertBackupMessageAttachmentToAttachment(
attachment,
this.#getFilePointerOptions()
)
)
.filter(isNotNil) .filter(isNotNil)
: undefined, : undefined,
preview: data.linkPreview?.length preview: data.linkPreview?.length
@ -1902,7 +1914,10 @@ export class BackupImportStream extends Writable {
description: dropNull(preview.description), description: dropNull(preview.description),
date: getCheckedTimestampOrUndefinedFromLong(preview.date), date: getCheckedTimestampOrUndefinedFromLong(preview.date),
image: preview.image image: preview.image
? convertFilePointerToAttachment(preview.image) ? convertFilePointerToAttachment(
preview.image,
this.#getFilePointerOptions()
)
: undefined, : undefined,
}; };
}) })
@ -1917,7 +1932,10 @@ export class BackupImportStream extends Writable {
...(attachment ...(attachment
? { ? {
attachments: [ attachments: [
convertBackupMessageAttachmentToAttachment(attachment), convertBackupMessageAttachmentToAttachment(
attachment,
this.#getFilePointerOptions()
),
].filter(isNotNil), ].filter(isNotNil),
} }
: { : {
@ -1948,7 +1966,10 @@ export class BackupImportStream extends Writable {
result.body = textReply.text?.body ?? undefined; result.body = textReply.text?.body ?? undefined;
result.bodyRanges = this.#fromBodyRanges(textReply.text); result.bodyRanges = this.#fromBodyRanges(textReply.text);
result.bodyAttachment = textReply.longText result.bodyAttachment = textReply.longText
? convertFilePointerToAttachment(textReply.longText) ? convertFilePointerToAttachment(
textReply.longText,
this.#getFilePointerOptions()
)
: undefined; : undefined;
} else if (emoji) { } else if (emoji) {
result.storyReaction = { result.storyReaction = {
@ -1978,7 +1999,10 @@ export class BackupImportStream extends Writable {
body: textReply.text?.body ?? undefined, body: textReply.text?.body ?? undefined,
bodyRanges: this.#fromBodyRanges(textReply.text), bodyRanges: this.#fromBodyRanges(textReply.text),
bodyAttachment: textReply.longText bodyAttachment: textReply.longText
? convertFilePointerToAttachment(textReply.longText) ? convertFilePointerToAttachment(
textReply.longText,
this.#getFilePointerOptions()
)
: undefined, : undefined,
}; };
} }
@ -2101,7 +2125,10 @@ export class BackupImportStream extends Writable {
? stringToMIMEType(contentType) ? stringToMIMEType(contentType)
: APPLICATION_OCTET_STREAM, : APPLICATION_OCTET_STREAM,
thumbnail: thumbnail?.pointer thumbnail: thumbnail?.pointer
? convertFilePointerToAttachment(thumbnail.pointer) ? convertFilePointerToAttachment(
thumbnail.pointer,
this.#getFilePointerOptions()
)
: undefined, : undefined,
}; };
}) ?? [], }) ?? [],
@ -2263,7 +2290,10 @@ export class BackupImportStream extends Writable {
organization: organization || undefined, organization: organization || undefined,
avatar: avatar avatar: avatar
? { ? {
avatar: convertFilePointerToAttachment(avatar), avatar: convertFilePointerToAttachment(
avatar,
this.#getFilePointerOptions()
),
isProfile: false, isProfile: false,
} }
: undefined, : undefined,
@ -2310,7 +2340,12 @@ export class BackupImportStream extends Writable {
packId: Bytes.toHex(packId), packId: Bytes.toHex(packId),
packKey: Bytes.toBase64(packKey), packKey: Bytes.toBase64(packKey),
stickerId, stickerId,
data: data ? convertFilePointerToAttachment(data) : undefined, data: data
? convertFilePointerToAttachment(
data,
this.#getFilePointerOptions()
)
: undefined,
}, },
reactions: this.#fromReactions(chatItem.stickerMessage.reactions), reactions: this.#fromReactions(chatItem.stickerMessage.reactions),
}, },
@ -3691,6 +3726,14 @@ export class BackupImportStream extends Writable {
autoBubbleColor, autoBubbleColor,
}; };
} }
#getFilePointerOptions() {
if (this.localBackupSnapshotDir != null) {
return { localBackupSnapshotDir: this.localBackupSnapshotDir };
}
return {};
}
} }
function rgbIntToDesktopHSL(intValue: number): { function rgbIntToDesktopHSL(intValue: number): {

View file

@ -5,15 +5,16 @@ import { pipeline } from 'stream/promises';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import type { Readable, Writable } from 'stream'; import type { Readable, Writable } from 'stream';
import { createReadStream, createWriteStream } from 'fs'; import { createReadStream, createWriteStream } from 'fs';
import { unlink, stat } from 'fs/promises'; import { mkdir, stat, unlink } from 'fs/promises';
import { ensureFile } from 'fs-extra'; import { ensureFile } from 'fs-extra';
import { join } from 'path'; import { join } from 'path';
import { createGzip, createGunzip } from 'zlib'; import { createGzip, createGunzip } from 'zlib';
import { createCipheriv, createHmac, randomBytes } from 'crypto'; import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash'; import { isEqual, noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys'; import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
import { throttle } from 'lodash/fp'; import { throttle } from 'lodash/fp';
import { ipcRenderer } from 'electron';
import { DataReader, DataWriter } from '../../sql/Client'; import { DataReader, DataWriter } from '../../sql/Client';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -49,7 +50,11 @@ import { isTestOrMockEnvironment } from '../../environment';
import { runStorageServiceSyncJob } from '../storage'; import { runStorageServiceSyncJob } from '../storage';
import { BackupExportStream, type StatsType } from './export'; import { BackupExportStream, type StatsType } from './export';
import { BackupImportStream } from './import'; import { BackupImportStream } from './import';
import { getKeyMaterial } from './crypto'; import {
getBackupId,
getKeyMaterial,
getLocalBackupMetadataKey,
} from './crypto';
import { BackupCredentials } from './credentials'; import { BackupCredentials } from './credentials';
import { BackupAPI } from './api'; import { BackupAPI } from './api';
import { validateBackup, ValidationType } from './validator'; import { validateBackup, ValidationType } from './validator';
@ -66,6 +71,16 @@ import { MemoryStream } from './util/MemoryStream';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version'; import { isAdhoc, isNightly } from '../../util/version';
import { getMessageQueueTime } from '../../util/getMessageQueueTime'; 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';
export { BackupType }; export { BackupType };
@ -94,6 +109,7 @@ type DoDownloadOptionsType = Readonly<{
export type ImportOptionsType = Readonly<{ export type ImportOptionsType = Readonly<{
backupType?: BackupType; backupType?: BackupType;
localBackupSnapshotDir?: string;
ephemeralKey?: Uint8Array; ephemeralKey?: Uint8Array;
onProgress?: (currentBytes: number, totalBytes: number) => void; onProgress?: (currentBytes: number, totalBytes: number) => void;
}>; }>;
@ -104,9 +120,13 @@ export type ExportResultType = Readonly<{
stats: Readonly<StatsType>; stats: Readonly<StatsType>;
}>; }>;
export type LocalBackupExportResultType = ExportResultType & {
snapshotDir: string;
};
export type ValidationResultType = Readonly< export type ValidationResultType = Readonly<
| { | {
result: ExportResultType; result: ExportResultType | LocalBackupExportResultType;
} }
| { | {
error: string; error: string;
@ -123,6 +143,8 @@ export class BackupsService {
| ExplodePromiseResultType<RetryBackupImportValue> | ExplodePromiseResultType<RetryBackupImportValue>
| undefined; | undefined;
#localBackupSnapshotDir: string | undefined;
public readonly credentials = new BackupCredentials(); public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials); public readonly api = new BackupAPI(this.credentials);
public readonly throttledFetchCloudBackupStatus = throttle( public readonly throttledFetchCloudBackupStatus = throttle(
@ -263,23 +285,7 @@ export class BackupsService {
} }
public async upload(): Promise<void> { public async upload(): Promise<void> {
// Make sure we are up-to-date on storage service await this.#waitForEmptyQueues('backups.upload');
{
const { promise: storageService, resolve } = explodePromise<void>();
window.Whisper.events.once('storageService:syncComplete', resolve);
runStorageServiceSyncJob({ reason: 'backups.upload' });
await storageService;
}
// Clear message queue
await window.waitForEmptyEventQueue();
// Make sure all batches are flushed
await Promise.all([
window.waitForAllBatchers(),
window.flushAllWaitBatchers(),
]);
const fileName = `backup-${randomBytes(32).toString('hex')}`; const fileName = `backup-${randomBytes(32).toString('hex')}`;
const filePath = join(window.BasePaths.temp, fileName); const filePath = join(window.BasePaths.temp, fileName);
@ -290,9 +296,9 @@ export class BackupsService {
log.info(`exportBackup: starting, backup level: ${backupLevel}...`); log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
try { try {
const fileSize = await this.exportToDisk(filePath, backupLevel); const { totalBytes } = await this.exportToDisk(filePath, backupLevel);
await this.api.upload(filePath, fileSize); await this.api.upload(filePath, totalBytes);
} finally { } finally {
try { try {
await unlink(filePath); await unlink(filePath);
@ -302,6 +308,95 @@ export class BackupsService {
} }
} }
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 // Test harness
public async exportBackupData( public async exportBackupData(
backupLevel: BackupLevel = BackupLevel.Free, backupLevel: BackupLevel = BackupLevel.Free,
@ -319,29 +414,63 @@ export class BackupsService {
}; };
} }
// Test harness
public async exportToDisk( public async exportToDisk(
path: string, path: string,
backupLevel: BackupLevel = BackupLevel.Free, backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext backupType = BackupType.Ciphertext,
): Promise<number> { localBackupSnapshotDir: string | undefined = undefined
const { totalBytes } = await this.#exportBackup( ): Promise<ExportResultType> {
const exportResult = await this.#exportBackup(
createWriteStream(path), createWriteStream(path),
backupLevel, backupLevel,
backupType backupType,
localBackupSnapshotDir
); );
if (backupType === BackupType.Ciphertext) { if (backupType === BackupType.Ciphertext) {
await validateBackup( await validateBackup(
() => new FileStream(path), () => new FileStream(path),
totalBytes, exportResult.totalBytes,
isTestOrMockEnvironment() isTestOrMockEnvironment()
? ValidationType.Internal ? ValidationType.Internal
: ValidationType.Export : ValidationType.Export
); );
} }
return totalBytes; 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 // Test harness
@ -417,6 +546,7 @@ export class BackupsService {
backupType = BackupType.Ciphertext, backupType = BackupType.Ciphertext,
ephemeralKey, ephemeralKey,
onProgress, onProgress,
localBackupSnapshotDir = undefined,
}: ImportOptionsType = {} }: ImportOptionsType = {}
): Promise<void> { ): Promise<void> {
strictAssert(!this.#isRunning, 'BackupService is already running'); strictAssert(!this.#isRunning, 'BackupService is already running');
@ -438,7 +568,10 @@ export class BackupsService {
window.ConversationController.setReadOnly(true); window.ConversationController.setReadOnly(true);
const importStream = await BackupImportStream.create(backupType); const importStream = await BackupImportStream.create(
backupType,
localBackupSnapshotDir
);
if (backupType === BackupType.Ciphertext) { if (backupType === BackupType.Ciphertext) {
const { aesKey, macKey } = getKeyMaterial( const { aesKey, macKey } = getKeyMaterial(
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
@ -761,7 +894,8 @@ export class BackupsService {
async #exportBackup( async #exportBackup(
sink: Writable, sink: Writable,
backupLevel: BackupLevel = BackupLevel.Free, backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext backupType = BackupType.Ciphertext,
localBackupSnapshotDir: string | undefined = undefined
): Promise<ExportResultType> { ): Promise<ExportResultType> {
strictAssert(!this.#isRunning, 'BackupService is already running'); strictAssert(!this.#isRunning, 'BackupService is already running');
@ -786,7 +920,7 @@ export class BackupsService {
const { aesKey, macKey } = getKeyMaterial(); const { aesKey, macKey } = getKeyMaterial();
const recordStream = new BackupExportStream(backupType); const recordStream = new BackupExportStream(backupType);
recordStream.run(backupLevel); recordStream.run(backupLevel, localBackupSnapshotDir);
const iv = randomBytes(IV_LENGTH); const iv = randomBytes(IV_LENGTH);
@ -817,6 +951,21 @@ export class BackupsService {
throw missingCaseError(backupType); 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; const duration = Date.now() - start;
return { totalBytes, stats: recordStream.getStats(), duration }; return { totalBytes, stats: recordStream.getStats(), duration };
} finally { } finally {
@ -866,6 +1015,28 @@ export class BackupsService {
window.reduxActions.installer.startInstaller(); 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 { public isImportRunning(): boolean {
return this.#isRunning === 'import'; return this.#isRunning === 'import';
} }

View file

@ -40,11 +40,17 @@ import { bytesToUuid } from '../../../util/uuidToBytes';
import { createName } from '../../../util/attachmentPath'; import { createName } from '../../../util/attachmentPath';
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable'; import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
import type { ReencryptionInfo } from '../../../AttachmentCrypto'; import type { ReencryptionInfo } from '../../../AttachmentCrypto';
import { getAttachmentLocalBackupPathFromSnapshotDir } from './localBackup';
type ConvertFilePointerToAttachmentOptions = {
// Only for testing
_createName: (suffix?: string) => string;
localBackupSnapshotDir: string | undefined;
};
export function convertFilePointerToAttachment( export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer, filePointer: Backups.FilePointer,
// Only for testing options: Partial<ConvertFilePointerToAttachmentOptions> = {}
{ _createName: doCreateName = createName } = {}
): AttachmentType { ): AttachmentType {
const { const {
contentType, contentType,
@ -58,7 +64,9 @@ export function convertFilePointerToAttachment(
attachmentLocator, attachmentLocator,
backupLocator, backupLocator,
invalidAttachmentLocator, invalidAttachmentLocator,
localLocator,
} = filePointer; } = filePointer;
const doCreateName = options._createName ?? createName;
const commonProps: Omit<AttachmentType, 'size'> = { const commonProps: Omit<AttachmentType, 'size'> = {
contentType: contentType contentType: contentType
@ -122,6 +130,57 @@ export function convertFilePointerToAttachment(
}; };
} }
if (localLocator) {
const {
mediaName,
localKey,
backupCdnNumber,
remoteKey: key,
remoteDigest: digest,
size,
transitCdnKey,
transitCdnNumber,
} = localLocator;
const { localBackupSnapshotDir } = options;
strictAssert(
localBackupSnapshotDir,
'localBackupSnapshotDir is required for filePointer.localLocator'
);
if (mediaName == null) {
log.error(
'convertFilePointerToAttachment: filePointer.localLocator missing mediaName!'
);
return {
...omit(commonProps, 'downloadPath'),
error: true,
size: 0,
};
}
const localBackupPath = getAttachmentLocalBackupPathFromSnapshotDir(
mediaName,
localBackupSnapshotDir
);
return {
...commonProps,
cdnKey: transitCdnKey ?? undefined,
cdnNumber: transitCdnNumber ?? undefined,
key: key?.length ? Bytes.toBase64(key) : undefined,
digest: digest?.length ? Bytes.toBase64(digest) : undefined,
size: size ?? 0,
localBackupPath,
localKey: localKey?.length ? Bytes.toBase64(localKey) : undefined,
backupLocator: backupCdnNumber
? {
mediaName,
cdnNumber: backupCdnNumber,
}
: undefined,
};
}
if (!invalidAttachmentLocator) { if (!invalidAttachmentLocator) {
log.error('convertFilePointerToAttachment: filePointer had no locator'); log.error('convertFilePointerToAttachment: filePointer had no locator');
} }
@ -134,7 +193,8 @@ export function convertFilePointerToAttachment(
} }
export function convertBackupMessageAttachmentToAttachment( export function convertBackupMessageAttachmentToAttachment(
messageAttachment: Backups.IMessageAttachment messageAttachment: Backups.IMessageAttachment,
options: Partial<ConvertFilePointerToAttachmentOptions> = {}
): AttachmentType | null { ): AttachmentType | null {
const { clientUuid } = messageAttachment; const { clientUuid } = messageAttachment;
@ -142,7 +202,7 @@ export function convertBackupMessageAttachmentToAttachment(
return null; return null;
} }
const result = { const result = {
...convertFilePointerToAttachment(messageAttachment.pointer), ...convertFilePointerToAttachment(messageAttachment.pointer, options),
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined, clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
}; };
@ -372,6 +432,99 @@ export async function getFilePointerForAttachment({
}; };
} }
// Given a remote backup FilePointer, return a FilePointer referencing a local backup
export async function getLocalBackupFilePointerForAttachment({
attachment,
backupLevel,
getBackupCdnInfo,
}: {
attachment: Readonly<AttachmentType>;
backupLevel: BackupLevel;
getBackupCdnInfo: GetBackupCdnInfoType;
}): Promise<{
filePointer: Backups.FilePointer;
updatedAttachment?: AttachmentType;
}> {
const { filePointer: remoteFilePointer, updatedAttachment } =
await getFilePointerForAttachment({
attachment,
backupLevel,
getBackupCdnInfo,
});
if (attachment.localKey == null) {
return { filePointer: remoteFilePointer, updatedAttachment };
}
strictAssert(
attachment.localKey != null,
'getLocalBackupFilePointerForAttachment: attachment must have localKey'
);
if (remoteFilePointer.backupLocator) {
const { backupLocator } = remoteFilePointer;
const { mediaName } = backupLocator;
strictAssert(
mediaName,
'getLocalBackupFilePointerForAttachment: BackupLocator must have mediaName'
);
const localLocator = new Backups.FilePointer.LocalLocator({
mediaName,
localKey: Bytes.fromBase64(attachment.localKey),
remoteKey: backupLocator.key,
remoteDigest: backupLocator.digest,
size: backupLocator.size,
backupCdnNumber: backupLocator.cdnNumber,
transitCdnKey: backupLocator.transitCdnKey,
transitCdnNumber: backupLocator.transitCdnNumber,
});
return {
filePointer: {
...omit(remoteFilePointer, 'backupLocator'),
localLocator,
},
updatedAttachment,
};
}
if (remoteFilePointer.attachmentLocator) {
const { attachmentLocator } = remoteFilePointer;
const { digest } = attachmentLocator;
strictAssert(
digest,
'getLocalBackupFilePointerForAttachment: AttachmentLocator must have digest'
);
const mediaName = getMediaNameFromDigest(Bytes.toBase64(digest));
strictAssert(
mediaName,
'getLocalBackupFilePointerForAttachment: mediaName must be derivable from AttachmentLocator'
);
const localLocator = new Backups.FilePointer.LocalLocator({
mediaName,
localKey: Bytes.fromBase64(attachment.localKey),
remoteKey: attachmentLocator.key,
remoteDigest: attachmentLocator.digest,
size: attachmentLocator.size,
backupCdnNumber: undefined,
transitCdnKey: attachmentLocator.cdnKey,
transitCdnNumber: attachmentLocator.cdnNumber,
});
return {
filePointer: {
...omit(remoteFilePointer, 'attachmentLocator'),
localLocator,
},
updatedAttachment,
};
}
return { filePointer: remoteFilePointer, updatedAttachment };
}
function getAttachmentLocator( function getAttachmentLocator(
attachment: AttachmentDownloadableFromTransitTier attachment: AttachmentDownloadableFromTransitTier
) { ) {
@ -419,21 +572,33 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
getBackupCdnInfo: GetBackupCdnInfoType; getBackupCdnInfo: GetBackupCdnInfoType;
messageReceivedAt: number; messageReceivedAt: number;
}): Promise<CoreAttachmentBackupJobType | null> { }): Promise<CoreAttachmentBackupJobType | null> {
if (!filePointer.backupLocator) { let locator:
| Backups.FilePointer.IBackupLocator
| Backups.FilePointer.ILocalLocator;
if (filePointer.backupLocator) {
locator = filePointer.backupLocator;
} else if (filePointer.localLocator) {
locator = filePointer.localLocator;
} else {
return null; return null;
} }
const { mediaName } = filePointer.backupLocator; const { mediaName } = locator;
strictAssert(mediaName, 'mediaName must exist'); strictAssert(mediaName, 'mediaName must exist');
const { isInBackupTier } = await getBackupCdnInfo( if (filePointer.backupLocator) {
getMediaIdFromMediaName(mediaName).string const { isInBackupTier } = await getBackupCdnInfo(
); getMediaIdFromMediaName(mediaName).string
);
if (isInBackupTier) { if (isInBackupTier) {
return null; return null;
}
} }
// TODO: For local backups we don't want to double back up the same file, so
// we could check for the same file here and if it's found then return early.
// Also ok to skip downstream when the job runs.
strictAssert( strictAssert(
isAttachmentLocallySaved(attachment), isAttachmentLocallySaved(attachment),
'Attachment must be saved locally for it to be backed up' 'Attachment must be saved locally for it to be backed up'
@ -455,19 +620,38 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
encryptionInfo = attachment.reencryptionInfo; encryptionInfo = attachment.reencryptionInfo;
} }
strictAssert( if (filePointer.backupLocator) {
filePointer.backupLocator.digest, strictAssert(
'digest must exist on backupLocator' filePointer.backupLocator.digest,
); 'digest must exist on backupLocator'
strictAssert( );
encryptionInfo.digest === Bytes.toBase64(filePointer.backupLocator.digest), strictAssert(
'digest on job and backupLocator must match' encryptionInfo.digest ===
); Bytes.toBase64(filePointer.backupLocator.digest),
'digest on job and backupLocator must match'
);
} else if (filePointer.localLocator) {
strictAssert(
filePointer.localLocator.remoteDigest,
'digest must exist on backupLocator'
);
strictAssert(
encryptionInfo.digest ===
Bytes.toBase64(filePointer.localLocator.remoteDigest),
'digest on job and localLocator must match'
);
}
const { path, contentType, size, uploadTimestamp, version, localKey } = const { path, contentType, size, uploadTimestamp, version } = attachment;
attachment;
const { transitCdnKey, transitCdnNumber } = filePointer.backupLocator; const { transitCdnKey, transitCdnNumber } = locator;
let localKey: string | undefined;
if (filePointer.localLocator && filePointer.localLocator.localKey != null) {
localKey = Bytes.toBase64(filePointer.localLocator.localKey);
} else {
localKey = attachment.localKey;
}
return { return {
mediaName, mediaName,

View file

@ -0,0 +1,296 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { randomBytes } from 'crypto';
import { dirname, join } from 'path';
import { readFile, stat, writeFile } from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { Transform } from 'stream';
import { pipeline } from 'stream/promises';
import * as log from '../../../logging/log';
import * as Bytes from '../../../Bytes';
import * as Errors from '../../../types/errors';
import { Signal } from '../../../protobuf';
import protobuf from '../../../protobuf/wrap';
import { strictAssert } from '../../../util/assert';
import { decryptAesCtr, encryptAesCtr } from '../../../Crypto';
import type { LocalBackupMetadataVerificationType } from '../../../types/backups';
import {
LOCAL_BACKUP_VERSION,
LOCAL_BACKUP_BACKUP_ID_IV_LENGTH,
} from '../constants';
import { explodePromise } from '../../../util/explodePromise';
const { Reader } = protobuf;
export function getLocalBackupDirectoryForMediaName({
backupsBaseDir,
mediaName,
}: {
backupsBaseDir: string;
mediaName: string;
}): string {
if (mediaName.length < 2) {
throw new Error('Invalid mediaName input');
}
return join(backupsBaseDir, 'files', 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,
mediaNamesIterator,
}: {
snapshotDir: string;
mediaNamesIterator: MapIterator<string>;
}): Promise<ReadonlyArray<string>> {
const { promise, resolve, reject } = explodePromise<ReadonlyArray<string>>();
const filesListPath = join(snapshotDir, 'files');
const writeStream = createWriteStream(filesListPath);
writeStream.on('error', error => {
reject(error);
});
const files: Array<string> = [];
for (const mediaName of mediaNamesIterator) {
const data = Signal.backup.local.FilesFrame.encodeDelimited({
mediaName,
}).finish();
if (!writeStream.write(data)) {
// eslint-disable-next-line no-await-in-loop
await new Promise(resolveStream =>
writeStream.once('drain', resolveStream)
);
}
files.push(mediaName);
}
writeStream.end(() => {
resolve(files);
});
await promise;
return files;
}
export async function readLocalBackupFilesList(
snapshotDir: string
): Promise<ReadonlyArray<string>> {
const filesListPath = join(snapshotDir, 'files');
const readStream = createReadStream(filesListPath);
const parseFilesTransform = new ParseFilesListTransform();
try {
await pipeline(readStream, parseFilesTransform);
} catch (error) {
try {
readStream.close();
} catch (closeError) {
log.error(
'readLocalBackupFilesList: Error when closing readStream',
Errors.toLogFormat(closeError)
);
}
throw error;
}
readStream.close();
return parseFilesTransform.mediaNames;
}
export class ParseFilesListTransform extends Transform {
public mediaNames: Array<string> = [];
public activeFile: Signal.backup.local.FilesFrame | undefined;
#unused: Uint8Array | undefined;
override async _transform(
chunk: Buffer | undefined,
_encoding: string,
done: (error?: Error) => void
): Promise<void> {
if (!chunk || chunk.byteLength === 0) {
done();
return;
}
try {
let data = chunk;
if (this.#unused) {
data = Buffer.concat([this.#unused, data]);
this.#unused = undefined;
}
const reader = Reader.create(data);
while (reader.pos < reader.len) {
const startPos = reader.pos;
if (!this.activeFile) {
try {
this.activeFile =
Signal.backup.local.FilesFrame.decodeDelimited(reader);
} catch (err) {
// We get a RangeError if there wasn't enough data to read the next record.
if (err instanceof RangeError) {
// Note: A failed decodeDelimited() does in fact update reader.pos, so we
// must reset to startPos
this.#unused = data.subarray(startPos);
done();
return;
}
// Something deeper has gone wrong; the proto is malformed or something
done(err);
return;
}
}
if (!this.activeFile) {
done(
new Error(
'ParseFilesListTransform: No active file after successful decode!'
)
);
return;
}
if (this.activeFile.mediaName) {
this.mediaNames.push(this.activeFile.mediaName);
} else {
log.warn(
'ParseFilesListTransform: Active file had empty mediaName, ignoring'
);
}
this.activeFile = undefined;
}
} catch (error) {
done(error);
return;
}
done();
}
}
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 };
}

View file

@ -4088,7 +4088,7 @@ const showSaveMultiDialog = (): Promise<{
canceled: boolean; canceled: boolean;
dirPath?: string; dirPath?: string;
}> => { }> => {
return ipcRenderer.invoke('show-save-multi-dialog'); return ipcRenderer.invoke('show-open-folder-dialog', { useMainWindow: true });
}; };
export type SaveAttachmentsActionCreatorType = ReadonlyDeep< export type SaveAttachmentsActionCreatorType = ReadonlyDeep<

View file

@ -50,6 +50,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}); });
@ -86,6 +87,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}), }),
@ -123,6 +125,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}); });
@ -161,6 +164,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}); });
@ -210,6 +214,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}); });
@ -260,6 +265,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal, abortSignal: abortController.signal,
}, },
dependencies: { dependencies: {
downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
}), }),

View file

@ -1,12 +1,15 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import fs from 'fs/promises';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { join } from 'node:path'; import { join } from 'node:path';
import os from 'os';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import createDebug from 'debug'; import createDebug from 'debug';
import Long from 'long'; import Long from 'long';
import { Proto, StorageState } from '@signalapp/mock-server'; import { Proto, StorageState } from '@signalapp/mock-server';
import { assert } from 'chai';
import { expect } from 'playwright/test'; import { expect } from 'playwright/test';
import { generateStoryDistributionId } from '../../types/StoryDistributionId'; import { generateStoryDistributionId } from '../../types/StoryDistributionId';
@ -17,7 +20,7 @@ import { IMAGE_JPEG } from '../../types/MIME';
import { uuidToBytes } from '../../util/uuidToBytes'; import { uuidToBytes } from '../../util/uuidToBytes';
import * as durations from '../../util/durations'; import * as durations from '../../util/durations';
import type { App } from '../playwright'; import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap'; import { Bootstrap, type LinkOptionsType } from '../bootstrap';
import { import {
getMessageInTimelineByTimestamp, getMessageInTimelineByTimestamp,
sendTextMessage, sendTextMessage,
@ -60,7 +63,11 @@ describe('backups', function (this: Mocha.Suite) {
await bootstrap.teardown(); await bootstrap.teardown();
}); });
it('exports and imports regular backup', async function () { async function generateTestDataThenRestoreBackup(
thisVal: Mocha.Context,
exportBackupFn: () => void,
getBootstrapLinkParams: () => LinkOptionsType
) {
let state = StorageState.getEmpty(); let state = StorageState.getEmpty();
const { phone, contacts } = bootstrap; const { phone, contacts } = bootstrap;
@ -237,7 +244,7 @@ describe('backups', function (this: Mocha.Suite) {
.waitFor(); .waitFor();
} }
await app.uploadBackup(); await exportBackupFn();
const comparator = await bootstrap.createScreenshotComparator( const comparator = await bootstrap.createScreenshotComparator(
app, app,
@ -288,7 +295,7 @@ describe('backups', function (this: Mocha.Suite) {
await snapshot('story privacy'); await snapshot('story privacy');
}, },
this.test thisVal.test
); );
await app.close(); await app.close();
@ -296,7 +303,7 @@ describe('backups', function (this: Mocha.Suite) {
// Restart // Restart
await bootstrap.eraseStorage(); await bootstrap.eraseStorage();
await server.removeAllCDNAttachments(); await server.removeAllCDNAttachments();
app = await bootstrap.link(); app = await bootstrap.link(getBootstrapLinkParams());
await app.waitForBackupImportComplete(); await app.waitForBackupImportComplete();
// Make sure that contact sync happens after backup import, otherwise the // Make sure that contact sync happens after backup import, otherwise the
@ -312,6 +319,35 @@ describe('backups', function (this: Mocha.Suite) {
} }
await comparator(app); await comparator(app);
}
it('exports and imports local backup', async function () {
let snapshotDir: string;
await generateTestDataThenRestoreBackup(
this,
async () => {
const backupsBaseDir = await fs.mkdtemp(
join(os.tmpdir(), 'SignalBackups')
);
snapshotDir = await app.exportLocalBackup(backupsBaseDir);
assert.exists(
snapshotDir,
'Local backup export should return backup dir'
);
},
() => ({ localBackup: snapshotDir })
);
});
it('exports and imports regular backup', async function () {
await generateTestDataThenRestoreBackup(
this,
async () => {
await app.uploadBackup();
},
() => ({})
);
}); });
it('imports ephemeral backup', async function () { it('imports ephemeral backup', async function () {

View file

@ -132,6 +132,7 @@ export type EphemeralBackupType = Readonly<
export type LinkOptionsType = Readonly<{ export type LinkOptionsType = Readonly<{
extraConfig?: Partial<RendererConfigType>; extraConfig?: Partial<RendererConfigType>;
ephemeralBackup?: EphemeralBackupType; ephemeralBackup?: EphemeralBackupType;
localBackup?: string;
}>; }>;
type BootstrapInternalOptions = BootstrapOptions & type BootstrapInternalOptions = BootstrapOptions &
@ -376,6 +377,7 @@ export class Bootstrap {
public async link({ public async link({
extraConfig, extraConfig,
ephemeralBackup, ephemeralBackup,
localBackup,
}: LinkOptionsType = {}): Promise<App> { }: LinkOptionsType = {}): Promise<App> {
debug('linking'); debug('linking');
@ -383,6 +385,10 @@ export class Bootstrap {
const window = await app.getWindow(); const window = await app.getWindow();
if (localBackup != null) {
await app.stageLocalBackupForImport(localBackup);
}
debug('looking for QR code or relink button'); debug('looking for QR code or relink button');
const qrCode = window.locator( const qrCode = window.locator(
'.module-InstallScreenQrCodeNotScannedStep__qr-code__code' '.module-InstallScreenQrCodeNotScannedStep__qr-code__code'

View file

@ -207,6 +207,20 @@ export class App extends EventEmitter {
return window.evaluate(`window.SignalCI.getMessagesBySentAt(${timestamp})`); return window.evaluate(`window.SignalCI.getMessagesBySentAt(${timestamp})`);
} }
public async exportLocalBackup(backupsBaseDir: string): Promise<string> {
const window = await this.getWindow();
return window.evaluate(
`window.SignalCI.exportLocalBackup('${backupsBaseDir}')`
);
}
public async stageLocalBackupForImport(snapshotDir: string): Promise<void> {
const window = await this.getWindow();
return window.evaluate(
`window.SignalCI.stageLocalBackupForImport('${snapshotDir}')`
);
}
public async uploadBackup(): Promise<void> { public async uploadBackup(): Promise<void> {
const window = await this.getWindow(); const window = await this.getWindow();
await window.evaluate('window.SignalCI.uploadBackup()'); await window.evaluate('window.SignalCI.uploadBackup()');

View file

@ -1209,6 +1209,8 @@ export default class AccountManager extends EventTarget {
} }
if (accountEntropyPool) { if (accountEntropyPool) {
await storage.put('accountEntropyPool', accountEntropyPool); await storage.put('accountEntropyPool', accountEntropyPool);
} else {
log.warn('createAccount: accountEntropyPool was missing!');
} }
let derivedMasterKey = masterKey; let derivedMasterKey = masterKey;
if (derivedMasterKey == null) { if (derivedMasterKey == null) {

View file

@ -55,7 +55,7 @@ export function getCdnKey(attachment: ProcessedAttachment): string {
return cdnKey; return cdnKey;
} }
function getBackupMediaOuterEncryptionKeyMaterial( export function getBackupMediaOuterEncryptionKeyMaterial(
attachment: AttachmentType attachment: AttachmentType
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachment(attachment); const mediaId = getMediaIdForAttachment(attachment);

View file

@ -109,6 +109,7 @@ export type AttachmentType = {
mediaName: string; mediaName: string;
cdnNumber?: number; cdnNumber?: number;
}; };
localBackupPath?: string;
// See app/attachment_channel.ts // See app/attachment_channel.ts
version?: 1 | 2; version?: 1 | 2;
@ -1281,6 +1282,12 @@ export function mightBeOnBackupTier(
return Boolean(attachment.backupLocator?.mediaName); return Boolean(attachment.backupLocator?.mediaName);
} }
export function mightBeInLocalBackup(
attachment: Pick<AttachmentType, 'localBackupPath' | 'localKey'>
): boolean {
return Boolean(attachment.localBackupPath && attachment.localKey);
}
export function isDownloadableFromTransitTier( export function isDownloadableFromTransitTier(
attachment: AttachmentType attachment: AttachmentType
): attachment is AttachmentDownloadableFromTransitTier { ): attachment is AttachmentDownloadableFromTransitTier {

View file

@ -45,6 +45,14 @@ export type ThumbnailAttachmentBackupJobType = {
}; };
}; };
export type CoreAttachmentLocalBackupJobType =
StandardAttachmentBackupJobType & {
backupsBaseDir: string;
};
export type AttachmentLocalBackupJobType = CoreAttachmentLocalBackupJobType &
JobManagerJobType;
const standardBackupJobDataSchema = z.object({ const standardBackupJobDataSchema = z.object({
type: z.literal('standard'), type: z.literal('standard'),
mediaName: z.string(), mediaName: z.string(),

View file

@ -61,3 +61,9 @@ export type BackupsSubscriptionType =
cost?: SubscriptionCostType; cost?: SubscriptionCostType;
} }
); );
export type LocalBackupMetadataVerificationType = {
snapshotDir: string;
backupId: Uint8Array;
metadataKey: Uint8Array;
};

View file

@ -69,7 +69,8 @@ import type {
BackupStatusType, BackupStatusType,
} from '../types/backups'; } from '../types/backups';
import { isBackupFeatureEnabled } from './isBackupEnabled'; import { isBackupFeatureEnabled } from './isBackupEnabled';
import * as RemoteConfig from '../RemoteConfig'; import { isSettingsInternalEnabled } from './isSettingsInternalEnabled';
import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
type SentMediaQualityType = 'standard' | 'high'; type SentMediaQualityType = 'standard' | 'high';
type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
@ -164,6 +165,8 @@ export type IPCEventsCallbacksType = {
unknownSignalLink: () => void; unknownSignalLink: () => void;
getCustomColors: () => Record<string, CustomColorType>; getCustomColors: () => Record<string, CustomColorType>;
syncRequest: () => Promise<void>; syncRequest: () => Promise<void>;
exportLocalBackup: () => Promise<BackupValidationResultType>;
importLocalBackup: () => Promise<ValidateLocalBackupStructureResultType>;
validateBackup: () => Promise<BackupValidationResultType>; validateBackup: () => Promise<BackupValidationResultType>;
setGlobalDefaultConversationColor: ( setGlobalDefaultConversationColor: (
color: ConversationColorType, color: ConversationColorType,
@ -542,7 +545,7 @@ export function createIPCEvents(
}, },
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1, isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
isInternalUser: () => RemoteConfig.isEnabled('desktop.internalUser'), isInternalUser: () => isSettingsInternalEnabled(),
syncRequest: async () => { syncRequest: async () => {
const contactSyncComplete = waitForEvent( const contactSyncComplete = waitForEvent(
'contactSync:complete', 'contactSync:complete',
@ -552,6 +555,9 @@ export function createIPCEvents(
return contactSyncComplete; return contactSyncComplete;
}, },
// Only for internal use // Only for internal use
exportLocalBackup: () => backupsService._internalExportLocalBackup(),
importLocalBackup: () =>
backupsService._internalStageLocalBackupForImport(),
validateBackup: () => backupsService._internalValidate(), validateBackup: () => backupsService._internalValidate(),
getLastSyncTime: () => window.storage.get('synced_at'), getLastSyncTime: () => window.storage.get('synced_at'),
setLastSyncTime: value => window.storage.put('synced_at', value), setLastSyncTime: value => window.storage.put('synced_at', value),

View file

@ -7,8 +7,10 @@ import {
AttachmentVariant, AttachmentVariant,
AttachmentPermanentlyUndownloadableError, AttachmentPermanentlyUndownloadableError,
getAttachmentIdForLogging, getAttachmentIdForLogging,
mightBeInLocalBackup,
} from '../types/Attachment'; } from '../types/Attachment';
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment'; import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
import { downloadAttachmentFromLocalBackup as doDownloadAttachmentFromLocalBackup } from './downloadAttachmentFromLocalBackup';
import { MediaTier } from '../types/AttachmentDownload'; import { MediaTier } from '../types/AttachmentDownload';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { HTTPError } from '../textsecure/Errors'; import { HTTPError } from '../textsecure/Errors';
@ -18,7 +20,10 @@ import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto';
export async function downloadAttachment({ export async function downloadAttachment({
attachment, attachment,
options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal }, options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
dependencies = { downloadAttachmentFromServer: doDownloadAttachment }, dependencies = {
downloadAttachmentFromServer: doDownloadAttachment,
downloadAttachmentFromLocalBackup: doDownloadAttachmentFromLocalBackup,
},
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
options: { options: {
@ -26,7 +31,10 @@ export async function downloadAttachment({
onSizeUpdate: (totalBytes: number) => void; onSizeUpdate: (totalBytes: number) => void;
abortSignal: AbortSignal; abortSignal: AbortSignal;
}; };
dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment }; dependencies?: {
downloadAttachmentFromServer: typeof doDownloadAttachment;
downloadAttachmentFromLocalBackup: typeof doDownloadAttachmentFromLocalBackup;
};
}): Promise<ReencryptedAttachmentV2> { }): Promise<ReencryptedAttachmentV2> {
const attachmentId = getAttachmentIdForLogging(attachment); const attachmentId = getAttachmentIdForLogging(attachment);
const variantForLogging = const variantForLogging =
@ -51,6 +59,23 @@ export async function downloadAttachment({
}; };
} }
if (mightBeInLocalBackup(attachment)) {
log.info(`${logId}: Downloading attachment from local backup`);
try {
const result =
await dependencies.downloadAttachmentFromLocalBackup(attachment);
onSizeUpdate(attachment.size);
return result;
} catch (error) {
// We also just log this error instead of throwing, since we want to still try to
// find it on the backup then transit tiers.
log.error(
`${logId}: error when downloading from local backup; will try backup and transit tier`,
toLogFormat(error)
);
}
}
if (mightBeOnBackupTier(migratedAttachment)) { if (mightBeOnBackupTier(migratedAttachment)) {
try { try {
return await dependencies.downloadAttachmentFromServer( return await dependencies.downloadAttachmentFromServer(

View file

@ -0,0 +1,61 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import {
type AttachmentType,
getAttachmentIdForLogging,
} from '../types/Attachment';
import * as log from '../logging/log';
import { toLogFormat } from '../types/errors';
import {
decryptAndReencryptLocally,
type ReencryptedAttachmentV2,
} from '../AttachmentCrypto';
import { strictAssert } from './assert';
export class AttachmentPermanentlyUndownloadableError extends Error {}
export async function downloadAttachmentFromLocalBackup(
attachment: AttachmentType
): Promise<ReencryptedAttachmentV2> {
const attachmentId = getAttachmentIdForLogging(attachment);
const dataId = `${attachmentId}`;
const logId = `downloadAttachmentFromLocalBackup(${dataId})`;
try {
return await doDownloadFromLocalBackup(attachment, { logId });
} catch (error) {
log.error(
`${logId}: error when copying from local backup`,
toLogFormat(error)
);
throw new AttachmentPermanentlyUndownloadableError();
}
}
async function doDownloadFromLocalBackup(
attachment: AttachmentType,
{
logId,
}: {
logId: string;
}
): Promise<ReencryptedAttachmentV2> {
const { digest, localBackupPath, localKey, size } = attachment;
strictAssert(digest, `${logId}: missing digest`);
strictAssert(localKey, `${logId}: missing localKey`);
strictAssert(localBackupPath, `${logId}: missing localBackupPath`);
strictAssert(isNumber(size), `${logId}: missing size`);
return decryptAndReencryptLocally({
type: 'local',
ciphertextPath: localBackupPath,
idForLogging: logId,
keysBase64: localKey,
size,
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
});
}

View file

@ -0,0 +1,24 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as RemoteConfig from '../RemoteConfig';
import { isTestOrMockEnvironment } from '../environment';
import { isStagingServer } from './isStagingServer';
import { isNightly } from './version';
export function isLocalBackupsEnabled(): boolean {
if (isStagingServer() || isTestOrMockEnvironment()) {
return true;
}
if (RemoteConfig.isEnabled('desktop.internalUser')) {
return true;
}
const version = window.getVersion?.();
if (version != null) {
return isNightly(version);
}
return false;
}

View file

@ -0,0 +1,18 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as RemoteConfig from '../RemoteConfig';
import { isNightly } from './version';
export function isSettingsInternalEnabled(): boolean {
if (RemoteConfig.isEnabled('desktop.internalUser')) {
return true;
}
const version = window.getVersion?.();
if (version != null) {
return isNightly(version);
}
return false;
}

View file

@ -51,6 +51,8 @@ installCallback('isInternalUser');
installCallback('syncRequest'); installCallback('syncRequest');
installCallback('getEmojiSkinToneDefault'); installCallback('getEmojiSkinToneDefault');
installCallback('setEmojiSkinToneDefault'); installCallback('setEmojiSkinToneDefault');
installCallback('exportLocalBackup');
installCallback('importLocalBackup');
installCallback('validateBackup'); installCallback('validateBackup');
installSetting('alwaysRelayCalls'); installSetting('alwaysRelayCalls');

View file

@ -43,6 +43,7 @@ SettingsWindowProps.onRender(
doDeleteAllData, doDeleteAllData,
doneRendering, doneRendering,
editCustomColor, editCustomColor,
exportLocalBackup,
getConversationsWithCustomColor, getConversationsWithCustomColor,
hasAudioNotifications, hasAudioNotifications,
hasAutoConvertEmoji, hasAutoConvertEmoji,
@ -67,6 +68,7 @@ SettingsWindowProps.onRender(
hasStoriesDisabled, hasStoriesDisabled,
hasTextFormatting, hasTextFormatting,
hasTypingIndicators, hasTypingIndicators,
importLocalBackup,
initialSpellCheckSetting, initialSpellCheckSetting,
isAutoDownloadUpdatesSupported, isAutoDownloadUpdatesSupported,
isAutoLaunchSupported, isAutoLaunchSupported,
@ -152,6 +154,7 @@ SettingsWindowProps.onRender(
defaultConversationColor={defaultConversationColor} defaultConversationColor={defaultConversationColor}
deviceName={deviceName} deviceName={deviceName}
emojiSkinToneDefault={emojiSkinToneDefault} emojiSkinToneDefault={emojiSkinToneDefault}
exportLocalBackup={exportLocalBackup}
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData} doDeleteAllData={doDeleteAllData}
doneRendering={doneRendering} doneRendering={doneRendering}
@ -181,6 +184,7 @@ SettingsWindowProps.onRender(
hasTextFormatting={hasTextFormatting} hasTextFormatting={hasTextFormatting}
hasTypingIndicators={hasTypingIndicators} hasTypingIndicators={hasTypingIndicators}
i18n={i18n} i18n={i18n}
importLocalBackup={importLocalBackup}
initialSpellCheckSetting={initialSpellCheckSetting} initialSpellCheckSetting={initialSpellCheckSetting}
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported} isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
isAutoLaunchSupported={isAutoLaunchSupported} isAutoLaunchSupported={isAutoLaunchSupported}

View file

@ -100,6 +100,8 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
const ipcIsSyncNotSupported = createCallback('isPrimary'); const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcIsInternalUser = createCallback('isInternalUser'); const ipcIsInternalUser = createCallback('isInternalUser');
const ipcMakeSyncRequest = createCallback('syncRequest'); const ipcMakeSyncRequest = createCallback('syncRequest');
const ipcExportLocalBackup = createCallback('exportLocalBackup');
const ipcImportLocalBackup = createCallback('importLocalBackup');
const ipcValidateBackup = createCallback('validateBackup'); const ipcValidateBackup = createCallback('validateBackup');
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories'); const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus'); const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
@ -369,6 +371,8 @@ async function renderPreferences() {
resetAllChatColors: ipcResetAllChatColors, resetAllChatColors: ipcResetAllChatColors,
resetDefaultChatColor: ipcResetDefaultChatColor, resetDefaultChatColor: ipcResetDefaultChatColor,
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor, setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
exportLocalBackup: ipcExportLocalBackup,
importLocalBackup: ipcImportLocalBackup,
validateBackup: ipcValidateBackup, validateBackup: ipcValidateBackup,
// Limited support features // Limited support features
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported( isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(