diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 10fe79e63a..02d8dad7e9 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -6652,6 +6652,26 @@
"messageformat": "Internal",
"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": {
"messageformat": "Export encrypted backup to memory and run validation suite on it",
"description": "Description of the internal backup validation tool"
diff --git a/app/main.ts b/app/main.ts
index 44b8c4cc85..802bc412db 100644
--- a/app/main.ts
+++ b/app/main.ts
@@ -3110,31 +3110,50 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
return { canceled: false, filePath: finalFilePath };
});
-ipc.handle('show-save-multi-dialog', async _event => {
- if (!mainWindow) {
- getLogger().warn('show-save-multi-dialog: no main window');
+ipc.handle(
+ 'show-open-folder-dialog',
+ async (
+ _event,
+ { useMainWindow }: { useMainWindow: boolean } = { useMainWindow: false }
+ ) => {
+ let canceled: boolean;
+ let selectedDirPaths: ReadonlyArray;
- return { canceled: true };
- }
- const { canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog(
- mainWindow,
- {
- defaultPath: app.getPath('downloads'),
- properties: ['openDirectory', 'createDirectory'],
+ if (useMainWindow) {
+ if (!mainWindow) {
+ getLogger().warn('show-open-folder-dialog: no main window');
+ return { canceled: true };
+ }
+
+ ({ 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) {
- return { canceled: true };
+
+ if (canceled || selectedDirPaths.length === 0) {
+ 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) => {
const role = untypedRole as MenuItemConstructorOptions['role'];
diff --git a/protos/Backups.proto b/protos/Backups.proto
index 01057d3630..9cde938185 100644
--- a/protos/Backups.proto
+++ b/protos/Backups.proto
@@ -725,11 +725,30 @@ message FilePointer {
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.
oneof locator {
BackupLocator backupLocator = 1;
AttachmentLocator attachmentLocator = 2;
InvalidAttachmentLocator invalidAttachmentLocator = 3;
+ LocalLocator localLocator = 12;
}
optional string contentType = 4;
diff --git a/protos/LocalBackup.proto b/protos/LocalBackup.proto
new file mode 100644
index 0000000000..5381d79071
--- /dev/null
+++ b/protos/LocalBackup.proto
@@ -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;
+ }
+}
diff --git a/ts/CI.ts b/ts/CI.ts
index ed9460c5aa..237d003490 100644
--- a/ts/CI.ts
+++ b/ts/CI.ts
@@ -43,6 +43,8 @@ export type CIType = {
) => unknown;
openSignalRoute(url: string): Promise;
migrateAllMessages(): Promise;
+ exportLocalBackup(backupsBaseDir: string): Promise;
+ stageLocalBackupForImport(snapshotDir: string): Promise;
uploadBackup(): Promise;
unlink: () => void;
print: (...args: ReadonlyArray) => void;
@@ -193,6 +195,20 @@ export function getCI({
document.body.removeChild(a);
}
+ async function exportLocalBackup(backupsBaseDir: string): Promise {
+ const { snapshotDir } =
+ await backupsService.exportLocalBackup(backupsBaseDir);
+ return snapshotDir;
+ }
+
+ async function stageLocalBackupForImport(snapshotDir: string): Promise {
+ const { error } =
+ await backupsService.stageLocalBackupForImport(snapshotDir);
+ if (error) {
+ throw error;
+ }
+ }
+
async function uploadBackup() {
await backupsService.upload();
await AttachmentBackupManager.waitForIdle();
@@ -237,6 +253,8 @@ export function getCI({
waitForEvent,
openSignalRoute,
migrateAllMessages,
+ exportLocalBackup,
+ stageLocalBackupForImport,
uploadBackup,
unlink,
getPendingEventCount,
diff --git a/ts/background.ts b/ts/background.ts
index 4958161fe9..64a16490a1 100644
--- a/ts/background.ts
+++ b/ts/background.ts
@@ -214,6 +214,7 @@ import { MessageModel } from './models/messages';
import { waitForEvent } from './shims/events';
import { sendSyncRequests } from './textsecure/syncRequests';
import { handleServerAlerts } from './util/handleServerAlerts';
+import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@@ -1758,7 +1759,7 @@ export async function startApp(): Promise {
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();
log.info(logId, {
wasBackupImported,
@@ -1836,20 +1837,29 @@ export async function startApp(): Promise {
wasBackupImported: boolean;
}> {
const backupDownloadPath = window.storage.get('backupDownloadPath');
- if (backupDownloadPath) {
+ const isLocalBackupAvailable =
+ backupsService.isLocalBackupStaged() && isLocalBackupsEnabled();
+
+ if (isLocalBackupAvailable || backupDownloadPath) {
tapToViewMessagesDeletionService.pause();
// Download backup before enabling request handler and storage service
try {
- const { wasBackupImported } = await backupsService.downloadAndImport({
- onProgress: (backupStep, currentBytes, totalBytes) => {
- window.reduxActions.installer.updateBackupImportProgress({
- backupStep,
- currentBytes,
- totalBytes,
- });
- },
- });
+ let wasBackupImported = false;
+ if (isLocalBackupAvailable) {
+ await backupsService.importLocalBackup();
+ wasBackupImported = true;
+ } else {
+ ({ wasBackupImported } = await backupsService.downloadAndImport({
+ onProgress: (backupStep, currentBytes, totalBytes) => {
+ window.reduxActions.installer.updateBackupImportProgress({
+ backupStep,
+ currentBytes,
+ totalBytes,
+ });
+ },
+ }));
+ }
log.info('afterAppStart: backup download attempt completed, resolving');
backupReady.resolve({ wasBackupImported });
diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx
index 5acbc90e6b..a33e98271a 100644
--- a/ts/components/Preferences.stories.tsx
+++ b/ts/components/Preferences.stories.tsx
@@ -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 {
title: 'Components/Preferences',
component: Preferences,
@@ -138,6 +160,18 @@ export default {
doDeleteAllData: action('doDeleteAllData'),
doneRendering: action('doneRendering'),
editCustomColor: action('editCustomColor'),
+ exportLocalBackup: async () => {
+ return {
+ result: exportLocalBackupResult,
+ };
+ },
+ importLocalBackup: async () => {
+ return {
+ success: true,
+ error: undefined,
+ snapshotDir: exportLocalBackupResult.snapshotDir,
+ };
+ },
makeSyncRequest: action('makeSyncRequest'),
onAudioNotificationsChange: action('onAudioNotificationsChange'),
onAutoConvertEmojiChange: action('onAutoConvertEmojiChange'),
@@ -192,22 +226,7 @@ export default {
),
validateBackup: async () => {
return {
- result: {
- 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,
- },
- },
+ result: validateBackupResult,
};
},
} satisfies PropsType,
diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx
index c40397a040..857ac814b9 100644
--- a/ts/components/Preferences.tsx
+++ b/ts/components/Preferences.tsx
@@ -74,6 +74,7 @@ import {
import { PreferencesBackups } from './PreferencesBackups';
import { PreferencesInternal } from './PreferencesInternal';
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
+import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType = (value: T) => unknown;
@@ -157,9 +158,11 @@ type PropsFunctionType = {
doDeleteAllData: () => unknown;
doneRendering: () => unknown;
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
+ exportLocalBackup: () => Promise;
getConversationsWithCustomColor: (
colorId: string
) => Promise>;
+ importLocalBackup: () => Promise;
makeSyncRequest: () => unknown;
refreshCloudBackupStatus: () => void;
refreshBackupSubscriptionStatus: () => void;
@@ -286,6 +289,7 @@ export function Preferences({
doneRendering,
editCustomColor,
emojiSkinToneDefault,
+ exportLocalBackup,
getConversationsWithCustomColor,
hasAudioNotifications,
hasAutoConvertEmoji,
@@ -311,6 +315,7 @@ export function Preferences({
hasTextFormatting,
hasTypingIndicators,
i18n,
+ importLocalBackup,
initialPage = Page.General,
initialSpellCheckSetting,
isAutoDownloadUpdatesSupported,
@@ -1736,7 +1741,12 @@ export function Preferences({
);
} else if (page === Page.Internal) {
settings = (
-
+
);
}
diff --git a/ts/components/PreferencesInternal.tsx b/ts/components/PreferencesInternal.tsx
index d5dff2e928..8cfe39e024 100644
--- a/ts/components/PreferencesInternal.tsx
+++ b/ts/components/PreferencesInternal.tsx
@@ -7,17 +7,30 @@ import { toLogFormat } from '../types/errors';
import { formatFileSize } from '../util/formatFileSize';
import { SECOND } from '../util/durations';
import type { ValidationResultType as BackupValidationResultType } from '../services/backups';
+import type { ValidateLocalBackupStructureResultType } from '../services/backups/util/localBackup';
import { SettingsRow, SettingsControl } from './PreferencesUtil';
import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner';
export function PreferencesInternal({
i18n,
+ exportLocalBackup: doExportLocalBackup,
+ importLocalBackup: doImportLocalBackup,
validateBackup: doValidateBackup,
}: {
i18n: LocalizerType;
+ exportLocalBackup: () => Promise;
+ importLocalBackup: () => Promise;
validateBackup: () => Promise;
}): 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 [validationResult, setValidationResult] = useState<
BackupValidationResultType | undefined
@@ -35,34 +48,110 @@ export function PreferencesInternal({
}
}, [doValidateBackup]);
- let validationElem: JSX.Element | undefined;
- if (validationResult != null) {
- if ('result' in validationResult) {
- const {
- result: { totalBytes, stats, duration },
- } = validationResult;
+ const renderValidationResult = useCallback(
+ (
+ backupResult: BackupValidationResultType | undefined
+ ): JSX.Element | undefined => {
+ if (backupResult == null) {
+ return;
+ }
- validationElem = (
-
-
File size: {formatFileSize(totalBytes)}
-
Duration: {Math.round(duration / SECOND)}s
-
- {JSON.stringify(stats, null, 2)}
-
-
- );
- } else {
- const { error } = validationResult;
+ if ('result' in backupResult) {
+ const {
+ result: { totalBytes, stats, duration },
+ } = backupResult;
- validationElem = (
+ let snapshotDirEl: JSX.Element | undefined;
+ if ('snapshotDir' in backupResult.result) {
+ snapshotDirEl = (
+
+ Backup path:
+
+ {backupResult.result.snapshotDir}
+
+
+ );
+ }
+
+ return (
+
+ {snapshotDirEl}
+
Main file size: {formatFileSize(totalBytes)}
+
Duration: {Math.round(duration / SECOND)}s
+
+ {JSON.stringify(stats, null, 2)}
+
+
+ );
+ }
+
+ const { error } = backupResult;
+
+ return (
);
+ },
+ []
+ );
+
+ 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 (
+
+
+ {`Staged: ${snapshotDir}\n\nPlease link to finish import.`}
+
+
+ );
+ }
+
+ return (
+
+
+ {`Failed: ${error}`}
+
+
+ );
+ },
+ []
+ );
return (
<>
@@ -93,7 +182,49 @@ export function PreferencesInternal({
}
/>
- {validationElem}
+ {renderValidationResult(validationResult)}
+
+
+
+
+ {isExportPending ? (
+
+ ) : (
+ i18n('icu:Preferences__internal__export-local-backup')
+ )}
+
+ }
+ />
+
+ {renderValidationResult(exportResult)}
+
+
+ {i18n('icu:Preferences__internal__import-local-backup')}
+
+ }
+ />
+
+ {renderImportResult(importResult)}
>
);
diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts
index f3eb5b47dc..85989a90b2 100644
--- a/ts/jobs/AttachmentDownloadManager.ts
+++ b/ts/jobs/AttachmentDownloadManager.ts
@@ -24,6 +24,7 @@ import {
AttachmentVariant,
AttachmentPermanentlyUndownloadableError,
mightBeOnBackupTier,
+ mightBeInLocalBackup,
} from '../types/Attachment';
import { type ReadonlyMessageAttributesType } from '../model-types.d';
import { getMessageById } from '../messages/getMessageById';
@@ -285,9 +286,10 @@ export class AttachmentDownloadManager extends JobManager {
const downloadedThumbnail = await dependencies.downloadAttachment({
attachment,
diff --git a/ts/jobs/AttachmentLocalBackupManager.ts b/ts/jobs/AttachmentLocalBackupManager.ts
new file mode 100644
index 0000000000..311931072a
--- /dev/null
+++ b/ts/jobs/AttachmentLocalBackupManager.ts
@@ -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 {
+ static #instance: AttachmentLocalBackupManager | undefined;
+ readonly #jobsByMediaName = new Map();
+
+ static defaultParams: JobManagerParamsType =
+ {
+ 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 {
+ return AttachmentLocalBackupManager.instance.#jobsByMediaName;
+ }
+
+ static async start(): Promise {
+ log.info('AttachmentLocalBackupManager/starting');
+ await AttachmentLocalBackupManager.instance.start();
+ }
+
+ static async stop(): Promise {
+ log.info('AttachmentLocalBackupManager/stopping');
+ return AttachmentLocalBackupManager.#instance?.stop();
+ }
+
+ static async addJob(newJob: CoreAttachmentLocalBackupJobType): Promise {
+ return AttachmentLocalBackupManager.instance.addJob(newJob);
+ }
+
+ static async waitForIdle(): Promise {
+ return AttachmentLocalBackupManager.instance.waitForIdle();
+ }
+
+ static async markAllJobsInactive(): Promise {
+ for (const [mediaName, job] of AttachmentLocalBackupManager.jobs) {
+ AttachmentLocalBackupManager.jobs.set(mediaName, {
+ ...job,
+ active: false,
+ });
+ }
+ }
+
+ static async saveJob(job: AttachmentLocalBackupJobType): Promise {
+ AttachmentLocalBackupManager.jobs.set(job.mediaName, job);
+ }
+
+ static async removeJob(
+ job: Pick
+ ): Promise {
+ AttachmentLocalBackupManager.jobs.delete(job.mediaName);
+ }
+
+ static clearAllJobs(): void {
+ AttachmentLocalBackupManager.jobs.clear();
+ }
+
+ static async getNextJobs({
+ limit,
+ timestamp,
+ }: {
+ limit: number;
+ timestamp: number;
+ }): Promise> {
+ let countRemaining = limit;
+ const nextJobs: Array = [];
+ 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> {
+ 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 {
+ 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
+ );
+}
diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts
index f7293d8548..be40789087 100644
--- a/ts/main/settingsChannel.ts
+++ b/ts/main/settingsChannel.ts
@@ -68,6 +68,8 @@ export class SettingsChannel extends EventEmitter {
this.#installCallback('syncRequest');
this.#installCallback('setEmojiSkinToneDefault');
this.#installCallback('getEmojiSkinToneDefault');
+ this.#installCallback('exportLocalBackup');
+ this.#installCallback('importLocalBackup');
this.#installCallback('validateBackup');
// Backups
diff --git a/ts/services/backups/constants.ts b/ts/services/backups/constants.ts
index ea64bf8854..f5ba4ce8c3 100644
--- a/ts/services/backups/constants.ts
+++ b/ts/services/backups/constants.ts
@@ -6,6 +6,10 @@ import type { ConversationColorType } from '../../types/Colors';
export const BACKUP_VERSION = 1;
+export const LOCAL_BACKUP_VERSION = 1;
+
+export const LOCAL_BACKUP_BACKUP_ID_IV_LENGTH = 16;
+
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
diff --git a/ts/services/backups/crypto.ts b/ts/services/backups/crypto.ts
index 02ccfd059d..2b77b39572 100644
--- a/ts/services/backups/crypto.ts
+++ b/ts/services/backups/crypto.ts
@@ -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();
+}
diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts
index 17f64e22cc..f2d5fe072f 100644
--- a/ts/services/backups/export.ts
+++ b/ts/services/backups/export.ts
@@ -4,6 +4,7 @@
import Long from 'long';
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client';
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
+import { dirname } from 'path';
import pMap from 'p-map';
import pTimeout from 'p-timeout';
import { Readable } from 'stream';
@@ -125,11 +126,13 @@ import {
} from '../../types/Attachment';
import {
getFilePointerForAttachment,
+ getLocalBackupFilePointerForAttachment,
maybeGetBackupJobForAttachmentAndFilePointer,
} from './util/filePointers';
import { getBackupMediaRootKey } from './crypto';
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
+import { AttachmentLocalBackupManager } from '../../jobs/AttachmentLocalBackupManager';
import { getBackupCdnInfo } from './util/mediaId';
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { ReadStatus } from '../../messages/MessageReadStatus';
@@ -177,6 +180,7 @@ type ToChatItemOptionsType = Readonly<{
aboutMe: AboutMe;
callHistoryByCallId: Record;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
}>;
type NonBubbleOptionsType = Pick<
@@ -227,6 +231,7 @@ export class BackupExportStream extends Readable {
readonly #serviceIdToRecipientId = new Map();
readonly #e164ToRecipientId = new Map();
readonly #roomIdToRecipientId = new Map();
+ readonly #mediaNamesToFilePointers = new Map();
readonly #stats: StatsType = {
adHocCalls: 0,
callLinks: 0,
@@ -253,43 +258,82 @@ export class BackupExportStream extends Readable {
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(
(async () => {
log.info('BackupExportStream: starting...');
drop(AttachmentBackupManager.stop());
+ drop(AttachmentLocalBackupManager.stop());
log.info('BackupExportStream: message migration starting...');
await migrateAllMessages();
await pauseWriteAccess();
try {
- await this.#unsafeRun(backupLevel);
+ await this.#unsafeRun(backupLevel, isLocalBackup);
} catch (error) {
this.emit('error', error);
} finally {
await resumeWriteAccess();
- // 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)
- )
+ if (isLocalBackup) {
+ log.info(
+ `BackupExportStream: Adding ${this.#attachmentBackupJobs.length} jobs for AttachmentLocalBackupManager`
);
- 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');
}
})()
);
}
+ public getMediaNamesIterator(): MapIterator {
+ return this.#mediaNamesToFilePointers.keys();
+ }
+
public getStats(): Readonly {
return this.#stats;
}
- async #unsafeRun(backupLevel: BackupLevel): Promise {
+ async #unsafeRun(
+ backupLevel: BackupLevel,
+ isLocalBackup: boolean
+ ): Promise {
this.#ourConversation =
window.ConversationController.getOurConversationOrThrow().attributes;
this.push(
@@ -663,6 +707,7 @@ export class BackupExportStream extends Readable {
aboutMe,
callHistoryByCallId,
backupLevel,
+ isLocalBackup,
}),
{ concurrency: MAX_CONCURRENCY }
);
@@ -1093,7 +1138,12 @@ export class BackupExportStream extends Readable {
async #toChatItem(
message: MessageAttributesType,
- { aboutMe, callHistoryByCallId, backupLevel }: ToChatItemOptionsType
+ {
+ aboutMe,
+ callHistoryByCallId,
+ backupLevel,
+ isLocalBackup,
+ }: ToChatItemOptionsType
): Promise {
const conversation = window.ConversationController.get(
message.conversationId
@@ -1253,6 +1303,7 @@ export class BackupExportStream extends Readable {
result.viewOnceMessage = await this.#toViewOnceMessage({
message,
backupLevel,
+ isLocalBackup,
});
} else if (message.deletedForEveryone) {
result.remoteDeletedMessage = {};
@@ -1314,6 +1365,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({
attachment: contactDetails.avatar.avatar,
backupLevel,
+ isLocalBackup,
messageReceivedAt: message.received_at,
})
: undefined,
@@ -1335,6 +1387,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({
attachment: sticker.data,
backupLevel,
+ isLocalBackup,
messageReceivedAt: message.received_at,
})
: undefined;
@@ -1378,23 +1431,27 @@ export class BackupExportStream extends Readable {
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
message,
backupLevel,
+ isLocalBackup,
});
result.revisions = await this.#toChatItemRevisions(
result,
message,
- backupLevel
+ backupLevel,
+ isLocalBackup
);
} else {
result.standardMessage = await this.#toStandardMessage({
message,
backupLevel,
+ isLocalBackup,
});
result.revisions = await this.#toChatItemRevisions(
result,
message,
- backupLevel
+ backupLevel,
+ isLocalBackup
);
}
@@ -2297,9 +2354,11 @@ export class BackupExportStream extends Readable {
async #toQuote({
message,
backupLevel,
+ isLocalBackup,
}: {
message: Pick;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
}): Promise {
const { quote } = message;
if (!quote) {
@@ -2359,6 +2418,7 @@ export class BackupExportStream extends Readable {
attachment: attachment.thumbnail,
backupLevel,
message,
+ isLocalBackup,
})
: undefined,
};
@@ -2417,16 +2477,19 @@ export class BackupExportStream extends Readable {
attachment,
backupLevel,
message,
+ isLocalBackup,
}: {
attachment: AttachmentType;
backupLevel: BackupLevel;
message: Pick;
+ isLocalBackup: boolean;
}): Promise {
const { clientUuid } = attachment;
const filePointer = await this.#processAttachment({
attachment,
backupLevel,
messageReceivedAt: message.received_at,
+ isLocalBackup,
});
return new Backups.MessageAttachment({
@@ -2440,18 +2503,51 @@ export class BackupExportStream extends Readable {
async #processAttachment({
attachment,
backupLevel,
+ isLocalBackup,
messageReceivedAt,
}: {
attachment: AttachmentType;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
messageReceivedAt: number;
}): Promise {
- const { filePointer, updatedAttachment } =
- await getFilePointerForAttachment({
- attachment,
- backupLevel,
- getBackupCdnInfo,
- });
+ // We need to always get updatedAttachment in case the attachment wasn't reencryptable
+ // to the original digest. In that case mediaName will be based on updatedAttachment.
+ const { filePointer, updatedAttachment } = isLocalBackup
+ ? await getLocalBackupFilePointerForAttachment({
+ 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) {
// 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({
message,
backupLevel,
+ isLocalBackup,
}: {
message: Pick<
MessageAttributesType,
@@ -2652,11 +2749,13 @@ export class BackupExportStream extends Readable {
| 'received_at'
>;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
}): Promise {
return {
quote: await this.#toQuote({
message,
backupLevel,
+ isLocalBackup,
}),
attachments: message.attachments?.length
? await Promise.all(
@@ -2665,6 +2764,7 @@ export class BackupExportStream extends Readable {
attachment,
backupLevel,
message,
+ isLocalBackup,
});
})
)
@@ -2673,6 +2773,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({
attachment: message.bodyAttachment,
backupLevel,
+ isLocalBackup,
messageReceivedAt: message.received_at,
})
: undefined,
@@ -2697,6 +2798,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({
attachment: preview.image,
backupLevel,
+ isLocalBackup,
messageReceivedAt: message.received_at,
})
: undefined,
@@ -2711,6 +2813,7 @@ export class BackupExportStream extends Readable {
async #toDirectStoryReplyMessage({
message,
backupLevel,
+ isLocalBackup,
}: {
message: Pick<
MessageAttributesType,
@@ -2722,6 +2825,7 @@ export class BackupExportStream extends Readable {
| 'reactions'
>;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
}): Promise {
const result = new Backups.DirectStoryReplyMessage({
reactions: this.#getMessageReactions(message),
@@ -2735,6 +2839,7 @@ export class BackupExportStream extends Readable {
? await this.#processAttachment({
attachment: message.bodyAttachment,
backupLevel,
+ isLocalBackup,
messageReceivedAt: message.received_at,
})
: undefined,
@@ -2755,12 +2860,14 @@ export class BackupExportStream extends Readable {
async #toViewOnceMessage({
message,
backupLevel,
+ isLocalBackup,
}: {
message: Pick<
MessageAttributesType,
'attachments' | 'received_at' | 'reactions'
>;
backupLevel: BackupLevel;
+ isLocalBackup: boolean;
}): Promise {
const attachment = message.attachments?.at(0);
return {
@@ -2771,6 +2878,7 @@ export class BackupExportStream extends Readable {
attachment,
backupLevel,
message,
+ isLocalBackup,
}),
reactions: this.#getMessageReactions(message),
};
@@ -2779,7 +2887,8 @@ export class BackupExportStream extends Readable {
async #toChatItemRevisions(
parent: Backups.IChatItem,
message: MessageAttributesType,
- backupLevel: BackupLevel
+ backupLevel: BackupLevel,
+ isLocalBackup: boolean
): Promise | undefined> {
const { editHistory } = message;
if (editHistory == null) {
@@ -2818,11 +2927,13 @@ export class BackupExportStream extends Readable {
await this.#toDirectStoryReplyMessage({
message: history,
backupLevel,
+ isLocalBackup,
});
} else {
result.standardMessage = await this.#toStandardMessage({
message: history,
backupLevel,
+ isLocalBackup,
});
}
return result;
diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts
index 50a85f3588..7882876a42 100644
--- a/ts/services/backups/import.ts
+++ b/ts/services/backups/import.ts
@@ -244,18 +244,22 @@ export class BackupImportStream extends Writable {
#pendingGroupAvatars = new Map();
#frameErrorCount: number = 0;
- private constructor(private readonly backupType: BackupType) {
+ private constructor(
+ private readonly backupType: BackupType,
+ private readonly localBackupSnapshotDir: string | undefined
+ ) {
super({ objectMode: true });
}
public static async create(
- backupType = BackupType.Ciphertext
+ backupType = BackupType.Ciphertext,
+ localBackupSnapshotDir: string | undefined = undefined
): Promise {
await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs();
await resetBackupMediaDownloadProgress();
- return new BackupImportStream(backupType);
+ return new BackupImportStream(backupType, localBackupSnapshotDir);
}
override async _write(
@@ -1854,11 +1858,19 @@ export class BackupImportStream extends Writable {
bodyRanges: this.#fromBodyRanges(data.text),
})),
bodyAttachment: data.longText
- ? convertFilePointerToAttachment(data.longText)
+ ? convertFilePointerToAttachment(
+ data.longText,
+ this.#getFilePointerOptions()
+ )
: undefined,
attachments: data.attachments?.length
? data.attachments
- .map(convertBackupMessageAttachmentToAttachment)
+ .map(attachment =>
+ convertBackupMessageAttachmentToAttachment(
+ attachment,
+ this.#getFilePointerOptions()
+ )
+ )
.filter(isNotNil)
: undefined,
preview: data.linkPreview?.length
@@ -1902,7 +1914,10 @@ export class BackupImportStream extends Writable {
description: dropNull(preview.description),
date: getCheckedTimestampOrUndefinedFromLong(preview.date),
image: preview.image
- ? convertFilePointerToAttachment(preview.image)
+ ? convertFilePointerToAttachment(
+ preview.image,
+ this.#getFilePointerOptions()
+ )
: undefined,
};
})
@@ -1917,7 +1932,10 @@ export class BackupImportStream extends Writable {
...(attachment
? {
attachments: [
- convertBackupMessageAttachmentToAttachment(attachment),
+ convertBackupMessageAttachmentToAttachment(
+ attachment,
+ this.#getFilePointerOptions()
+ ),
].filter(isNotNil),
}
: {
@@ -1948,7 +1966,10 @@ export class BackupImportStream extends Writable {
result.body = textReply.text?.body ?? undefined;
result.bodyRanges = this.#fromBodyRanges(textReply.text);
result.bodyAttachment = textReply.longText
- ? convertFilePointerToAttachment(textReply.longText)
+ ? convertFilePointerToAttachment(
+ textReply.longText,
+ this.#getFilePointerOptions()
+ )
: undefined;
} else if (emoji) {
result.storyReaction = {
@@ -1978,7 +1999,10 @@ export class BackupImportStream extends Writable {
body: textReply.text?.body ?? undefined,
bodyRanges: this.#fromBodyRanges(textReply.text),
bodyAttachment: textReply.longText
- ? convertFilePointerToAttachment(textReply.longText)
+ ? convertFilePointerToAttachment(
+ textReply.longText,
+ this.#getFilePointerOptions()
+ )
: undefined,
};
}
@@ -2101,7 +2125,10 @@ export class BackupImportStream extends Writable {
? stringToMIMEType(contentType)
: APPLICATION_OCTET_STREAM,
thumbnail: thumbnail?.pointer
- ? convertFilePointerToAttachment(thumbnail.pointer)
+ ? convertFilePointerToAttachment(
+ thumbnail.pointer,
+ this.#getFilePointerOptions()
+ )
: undefined,
};
}) ?? [],
@@ -2263,7 +2290,10 @@ export class BackupImportStream extends Writable {
organization: organization || undefined,
avatar: avatar
? {
- avatar: convertFilePointerToAttachment(avatar),
+ avatar: convertFilePointerToAttachment(
+ avatar,
+ this.#getFilePointerOptions()
+ ),
isProfile: false,
}
: undefined,
@@ -2310,7 +2340,12 @@ export class BackupImportStream extends Writable {
packId: Bytes.toHex(packId),
packKey: Bytes.toBase64(packKey),
stickerId,
- data: data ? convertFilePointerToAttachment(data) : undefined,
+ data: data
+ ? convertFilePointerToAttachment(
+ data,
+ this.#getFilePointerOptions()
+ )
+ : undefined,
},
reactions: this.#fromReactions(chatItem.stickerMessage.reactions),
},
@@ -3691,6 +3726,14 @@ export class BackupImportStream extends Writable {
autoBubbleColor,
};
}
+
+ #getFilePointerOptions() {
+ if (this.localBackupSnapshotDir != null) {
+ return { localBackupSnapshotDir: this.localBackupSnapshotDir };
+ }
+
+ return {};
+ }
}
function rgbIntToDesktopHSL(intValue: number): {
diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts
index 38e996e5ee..ae5bfaaaf6 100644
--- a/ts/services/backups/index.ts
+++ b/ts/services/backups/index.ts
@@ -5,15 +5,16 @@ import { pipeline } from 'stream/promises';
import { PassThrough } from 'stream';
import type { Readable, Writable } from 'stream';
import { createReadStream, createWriteStream } from 'fs';
-import { unlink, stat } from 'fs/promises';
+import { mkdir, stat, unlink } from 'fs/promises';
import { ensureFile } from 'fs-extra';
import { join } from 'path';
import { createGzip, createGunzip } from 'zlib';
import { createCipheriv, createHmac, randomBytes } from 'crypto';
-import { noop } from 'lodash';
+import { isEqual, noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
import { throttle } from 'lodash/fp';
+import { ipcRenderer } from 'electron';
import { DataReader, DataWriter } from '../../sql/Client';
import * as log from '../../logging/log';
@@ -49,7 +50,11 @@ import { isTestOrMockEnvironment } from '../../environment';
import { runStorageServiceSyncJob } from '../storage';
import { BackupExportStream, type StatsType } from './export';
import { BackupImportStream } from './import';
-import { getKeyMaterial } from './crypto';
+import {
+ getBackupId,
+ getKeyMaterial,
+ getLocalBackupMetadataKey,
+} from './crypto';
import { BackupCredentials } from './credentials';
import { BackupAPI } from './api';
import { validateBackup, ValidationType } from './validator';
@@ -66,6 +71,16 @@ import { MemoryStream } from './util/MemoryStream';
import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version';
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 };
@@ -94,6 +109,7 @@ type DoDownloadOptionsType = Readonly<{
export type ImportOptionsType = Readonly<{
backupType?: BackupType;
+ localBackupSnapshotDir?: string;
ephemeralKey?: Uint8Array;
onProgress?: (currentBytes: number, totalBytes: number) => void;
}>;
@@ -104,9 +120,13 @@ export type ExportResultType = Readonly<{
stats: Readonly;
}>;
+export type LocalBackupExportResultType = ExportResultType & {
+ snapshotDir: string;
+};
+
export type ValidationResultType = Readonly<
| {
- result: ExportResultType;
+ result: ExportResultType | LocalBackupExportResultType;
}
| {
error: string;
@@ -123,6 +143,8 @@ export class BackupsService {
| ExplodePromiseResultType
| undefined;
+ #localBackupSnapshotDir: string | undefined;
+
public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials);
public readonly throttledFetchCloudBackupStatus = throttle(
@@ -263,23 +285,7 @@ export class BackupsService {
}
public async upload(): Promise {
- // Make sure we are up-to-date on storage service
- {
- const { promise: storageService, resolve } = explodePromise();
- 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(),
- ]);
+ await this.#waitForEmptyQueues('backups.upload');
const fileName = `backup-${randomBytes(32).toString('hex')}`;
const filePath = join(window.BasePaths.temp, fileName);
@@ -290,9 +296,9 @@ export class BackupsService {
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
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 {
try {
await unlink(filePath);
@@ -302,6 +308,95 @@ export class BackupsService {
}
}
+ public async exportLocalBackup(
+ backupsBaseDir: string | undefined = undefined,
+ backupLevel: BackupLevel = BackupLevel.Free
+ ): Promise {
+ 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 {
+ 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 {
+ 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
public async exportBackupData(
backupLevel: BackupLevel = BackupLevel.Free,
@@ -319,29 +414,63 @@ export class BackupsService {
};
}
- // Test harness
public async exportToDisk(
path: string,
backupLevel: BackupLevel = BackupLevel.Free,
- backupType = BackupType.Ciphertext
- ): Promise {
- const { totalBytes } = await this.#exportBackup(
+ backupType = BackupType.Ciphertext,
+ localBackupSnapshotDir: string | undefined = undefined
+ ): Promise {
+ const exportResult = await this.#exportBackup(
createWriteStream(path),
backupLevel,
- backupType
+ backupType,
+ localBackupSnapshotDir
);
if (backupType === BackupType.Ciphertext) {
await validateBackup(
() => new FileStream(path),
- totalBytes,
+ exportResult.totalBytes,
isTestOrMockEnvironment()
? ValidationType.Internal
: ValidationType.Export
);
}
- return totalBytes;
+ return exportResult;
+ }
+
+ public async _internalExportLocalBackup(
+ backupLevel: BackupLevel = BackupLevel.Free
+ ): Promise {
+ 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 {
+ 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
@@ -417,6 +546,7 @@ export class BackupsService {
backupType = BackupType.Ciphertext,
ephemeralKey,
onProgress,
+ localBackupSnapshotDir = undefined,
}: ImportOptionsType = {}
): Promise {
strictAssert(!this.#isRunning, 'BackupService is already running');
@@ -438,7 +568,10 @@ export class BackupsService {
window.ConversationController.setReadOnly(true);
- const importStream = await BackupImportStream.create(backupType);
+ const importStream = await BackupImportStream.create(
+ backupType,
+ localBackupSnapshotDir
+ );
if (backupType === BackupType.Ciphertext) {
const { aesKey, macKey } = getKeyMaterial(
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
@@ -761,7 +894,8 @@ export class BackupsService {
async #exportBackup(
sink: Writable,
backupLevel: BackupLevel = BackupLevel.Free,
- backupType = BackupType.Ciphertext
+ backupType = BackupType.Ciphertext,
+ localBackupSnapshotDir: string | undefined = undefined
): Promise {
strictAssert(!this.#isRunning, 'BackupService is already running');
@@ -786,7 +920,7 @@ export class BackupsService {
const { aesKey, macKey } = getKeyMaterial();
const recordStream = new BackupExportStream(backupType);
- recordStream.run(backupLevel);
+ recordStream.run(backupLevel, localBackupSnapshotDir);
const iv = randomBytes(IV_LENGTH);
@@ -817,6 +951,21 @@ export class BackupsService {
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;
return { totalBytes, stats: recordStream.getStats(), duration };
} finally {
@@ -866,6 +1015,28 @@ export class BackupsService {
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();
+ 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 {
return this.#isRunning === 'import';
}
diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts
index 2a7096f2be..f6f3a24e28 100644
--- a/ts/services/backups/util/filePointers.ts
+++ b/ts/services/backups/util/filePointers.ts
@@ -40,11 +40,17 @@ import { bytesToUuid } from '../../../util/uuidToBytes';
import { createName } from '../../../util/attachmentPath';
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
import type { ReencryptionInfo } from '../../../AttachmentCrypto';
+import { getAttachmentLocalBackupPathFromSnapshotDir } from './localBackup';
+
+type ConvertFilePointerToAttachmentOptions = {
+ // Only for testing
+ _createName: (suffix?: string) => string;
+ localBackupSnapshotDir: string | undefined;
+};
export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer,
- // Only for testing
- { _createName: doCreateName = createName } = {}
+ options: Partial = {}
): AttachmentType {
const {
contentType,
@@ -58,7 +64,9 @@ export function convertFilePointerToAttachment(
attachmentLocator,
backupLocator,
invalidAttachmentLocator,
+ localLocator,
} = filePointer;
+ const doCreateName = options._createName ?? createName;
const commonProps: Omit = {
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) {
log.error('convertFilePointerToAttachment: filePointer had no locator');
}
@@ -134,7 +193,8 @@ export function convertFilePointerToAttachment(
}
export function convertBackupMessageAttachmentToAttachment(
- messageAttachment: Backups.IMessageAttachment
+ messageAttachment: Backups.IMessageAttachment,
+ options: Partial = {}
): AttachmentType | null {
const { clientUuid } = messageAttachment;
@@ -142,7 +202,7 @@ export function convertBackupMessageAttachmentToAttachment(
return null;
}
const result = {
- ...convertFilePointerToAttachment(messageAttachment.pointer),
+ ...convertFilePointerToAttachment(messageAttachment.pointer, options),
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;
+ 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(
attachment: AttachmentDownloadableFromTransitTier
) {
@@ -419,21 +572,33 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
getBackupCdnInfo: GetBackupCdnInfoType;
messageReceivedAt: number;
}): Promise {
- 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;
}
- const { mediaName } = filePointer.backupLocator;
+ const { mediaName } = locator;
strictAssert(mediaName, 'mediaName must exist');
- const { isInBackupTier } = await getBackupCdnInfo(
- getMediaIdFromMediaName(mediaName).string
- );
-
- if (isInBackupTier) {
- return null;
+ if (filePointer.backupLocator) {
+ const { isInBackupTier } = await getBackupCdnInfo(
+ getMediaIdFromMediaName(mediaName).string
+ );
+ if (isInBackupTier) {
+ 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(
isAttachmentLocallySaved(attachment),
'Attachment must be saved locally for it to be backed up'
@@ -455,19 +620,38 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
encryptionInfo = attachment.reencryptionInfo;
}
- strictAssert(
- filePointer.backupLocator.digest,
- 'digest must exist on backupLocator'
- );
- strictAssert(
- encryptionInfo.digest === Bytes.toBase64(filePointer.backupLocator.digest),
- 'digest on job and backupLocator must match'
- );
+ if (filePointer.backupLocator) {
+ strictAssert(
+ filePointer.backupLocator.digest,
+ 'digest must exist on backupLocator'
+ );
+ strictAssert(
+ 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 } =
- attachment;
+ const { path, contentType, size, uploadTimestamp, version } = 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 {
mediaName,
diff --git a/ts/services/backups/util/localBackup.ts b/ts/services/backups/util/localBackup.ts
new file mode 100644
index 0000000000..88a1180745
--- /dev/null
+++ b/ts/services/backups/util/localBackup.ts
@@ -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 {
+ 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 {
+ 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;
+}): Promise> {
+ const { promise, resolve, reject } = explodePromise>();
+
+ const filesListPath = join(snapshotDir, 'files');
+ const writeStream = createWriteStream(filesListPath);
+ writeStream.on('error', error => {
+ reject(error);
+ });
+
+ const files: Array = [];
+ 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> {
+ 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 = [];
+
+ public activeFile: Signal.backup.local.FilesFrame | undefined;
+ #unused: Uint8Array | undefined;
+
+ override async _transform(
+ chunk: Buffer | undefined,
+ _encoding: string,
+ done: (error?: Error) => void
+ ): Promise {
+ 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 {
+ 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 };
+}
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 92d9c46f51..34362f6d27 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -4088,7 +4088,7 @@ const showSaveMultiDialog = (): Promise<{
canceled: boolean;
dirPath?: string;
}> => {
- return ipcRenderer.invoke('show-save-multi-dialog');
+ return ipcRenderer.invoke('show-open-folder-dialog', { useMainWindow: true });
};
export type SaveAttachmentsActionCreatorType = ReadonlyDeep<
diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts
index 0eca5e9379..042bba8b97 100644
--- a/ts/test-electron/util/downloadAttachment_test.ts
+++ b/ts/test-electron/util/downloadAttachment_test.ts
@@ -50,6 +50,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
});
@@ -86,6 +87,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
}),
@@ -123,6 +125,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
});
@@ -161,6 +164,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
});
@@ -210,6 +214,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
});
@@ -260,6 +265,7 @@ describe('utils/downloadAttachment', () => {
abortSignal: abortController.signal,
},
dependencies: {
+ downloadAttachmentFromLocalBackup: stubDownload,
downloadAttachmentFromServer: stubDownload,
},
}),
diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts
index 5a836e6328..92b9d85152 100644
--- a/ts/test-mock/backups/backups_test.ts
+++ b/ts/test-mock/backups/backups_test.ts
@@ -1,12 +1,15 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import fs from 'fs/promises';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
+import os from 'os';
import { readFile } from 'node:fs/promises';
import createDebug from 'debug';
import Long from 'long';
import { Proto, StorageState } from '@signalapp/mock-server';
+import { assert } from 'chai';
import { expect } from 'playwright/test';
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
@@ -17,7 +20,7 @@ import { IMAGE_JPEG } from '../../types/MIME';
import { uuidToBytes } from '../../util/uuidToBytes';
import * as durations from '../../util/durations';
import type { App } from '../playwright';
-import { Bootstrap } from '../bootstrap';
+import { Bootstrap, type LinkOptionsType } from '../bootstrap';
import {
getMessageInTimelineByTimestamp,
sendTextMessage,
@@ -60,7 +63,11 @@ describe('backups', function (this: Mocha.Suite) {
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();
const { phone, contacts } = bootstrap;
@@ -237,7 +244,7 @@ describe('backups', function (this: Mocha.Suite) {
.waitFor();
}
- await app.uploadBackup();
+ await exportBackupFn();
const comparator = await bootstrap.createScreenshotComparator(
app,
@@ -288,7 +295,7 @@ describe('backups', function (this: Mocha.Suite) {
await snapshot('story privacy');
},
- this.test
+ thisVal.test
);
await app.close();
@@ -296,7 +303,7 @@ describe('backups', function (this: Mocha.Suite) {
// Restart
await bootstrap.eraseStorage();
await server.removeAllCDNAttachments();
- app = await bootstrap.link();
+ app = await bootstrap.link(getBootstrapLinkParams());
await app.waitForBackupImportComplete();
// Make sure that contact sync happens after backup import, otherwise the
@@ -312,6 +319,35 @@ describe('backups', function (this: Mocha.Suite) {
}
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 () {
diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts
index 563fa0bd14..0522f97bf0 100644
--- a/ts/test-mock/bootstrap.ts
+++ b/ts/test-mock/bootstrap.ts
@@ -132,6 +132,7 @@ export type EphemeralBackupType = Readonly<
export type LinkOptionsType = Readonly<{
extraConfig?: Partial;
ephemeralBackup?: EphemeralBackupType;
+ localBackup?: string;
}>;
type BootstrapInternalOptions = BootstrapOptions &
@@ -376,6 +377,7 @@ export class Bootstrap {
public async link({
extraConfig,
ephemeralBackup,
+ localBackup,
}: LinkOptionsType = {}): Promise {
debug('linking');
@@ -383,6 +385,10 @@ export class Bootstrap {
const window = await app.getWindow();
+ if (localBackup != null) {
+ await app.stageLocalBackupForImport(localBackup);
+ }
+
debug('looking for QR code or relink button');
const qrCode = window.locator(
'.module-InstallScreenQrCodeNotScannedStep__qr-code__code'
diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts
index 0d5b55774f..aec7be460e 100644
--- a/ts/test-mock/playwright.ts
+++ b/ts/test-mock/playwright.ts
@@ -207,6 +207,20 @@ export class App extends EventEmitter {
return window.evaluate(`window.SignalCI.getMessagesBySentAt(${timestamp})`);
}
+ public async exportLocalBackup(backupsBaseDir: string): Promise {
+ const window = await this.getWindow();
+ return window.evaluate(
+ `window.SignalCI.exportLocalBackup('${backupsBaseDir}')`
+ );
+ }
+
+ public async stageLocalBackupForImport(snapshotDir: string): Promise {
+ const window = await this.getWindow();
+ return window.evaluate(
+ `window.SignalCI.stageLocalBackupForImport('${snapshotDir}')`
+ );
+ }
+
public async uploadBackup(): Promise {
const window = await this.getWindow();
await window.evaluate('window.SignalCI.uploadBackup()');
diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts
index 82ffccf69b..65a00a91ed 100644
--- a/ts/textsecure/AccountManager.ts
+++ b/ts/textsecure/AccountManager.ts
@@ -1209,6 +1209,8 @@ export default class AccountManager extends EventTarget {
}
if (accountEntropyPool) {
await storage.put('accountEntropyPool', accountEntropyPool);
+ } else {
+ log.warn('createAccount: accountEntropyPool was missing!');
}
let derivedMasterKey = masterKey;
if (derivedMasterKey == null) {
diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts
index 5a39fca99b..41a671f655 100644
--- a/ts/textsecure/downloadAttachment.ts
+++ b/ts/textsecure/downloadAttachment.ts
@@ -55,7 +55,7 @@ export function getCdnKey(attachment: ProcessedAttachment): string {
return cdnKey;
}
-function getBackupMediaOuterEncryptionKeyMaterial(
+export function getBackupMediaOuterEncryptionKeyMaterial(
attachment: AttachmentType
): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachment(attachment);
diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts
index 572abcd309..3bec07d84e 100644
--- a/ts/types/Attachment.ts
+++ b/ts/types/Attachment.ts
@@ -109,6 +109,7 @@ export type AttachmentType = {
mediaName: string;
cdnNumber?: number;
};
+ localBackupPath?: string;
// See app/attachment_channel.ts
version?: 1 | 2;
@@ -1281,6 +1282,12 @@ export function mightBeOnBackupTier(
return Boolean(attachment.backupLocator?.mediaName);
}
+export function mightBeInLocalBackup(
+ attachment: Pick
+): boolean {
+ return Boolean(attachment.localBackupPath && attachment.localKey);
+}
+
export function isDownloadableFromTransitTier(
attachment: AttachmentType
): attachment is AttachmentDownloadableFromTransitTier {
diff --git a/ts/types/AttachmentBackup.ts b/ts/types/AttachmentBackup.ts
index d80c58b30c..5295b3836f 100644
--- a/ts/types/AttachmentBackup.ts
+++ b/ts/types/AttachmentBackup.ts
@@ -45,6 +45,14 @@ export type ThumbnailAttachmentBackupJobType = {
};
};
+export type CoreAttachmentLocalBackupJobType =
+ StandardAttachmentBackupJobType & {
+ backupsBaseDir: string;
+ };
+
+export type AttachmentLocalBackupJobType = CoreAttachmentLocalBackupJobType &
+ JobManagerJobType;
+
const standardBackupJobDataSchema = z.object({
type: z.literal('standard'),
mediaName: z.string(),
diff --git a/ts/types/backups.ts b/ts/types/backups.ts
index 290c73cd5e..9120a3ed32 100644
--- a/ts/types/backups.ts
+++ b/ts/types/backups.ts
@@ -61,3 +61,9 @@ export type BackupsSubscriptionType =
cost?: SubscriptionCostType;
}
);
+
+export type LocalBackupMetadataVerificationType = {
+ snapshotDir: string;
+ backupId: Uint8Array;
+ metadataKey: Uint8Array;
+};
diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts
index 44df51dabf..14bd6d8f68 100644
--- a/ts/util/createIPCEvents.ts
+++ b/ts/util/createIPCEvents.ts
@@ -69,7 +69,8 @@ import type {
BackupStatusType,
} from '../types/backups';
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 NotificationSettingType = 'message' | 'name' | 'count' | 'off';
@@ -164,6 +165,8 @@ export type IPCEventsCallbacksType = {
unknownSignalLink: () => void;
getCustomColors: () => Record;
syncRequest: () => Promise;
+ exportLocalBackup: () => Promise;
+ importLocalBackup: () => Promise;
validateBackup: () => Promise;
setGlobalDefaultConversationColor: (
color: ConversationColorType,
@@ -542,7 +545,7 @@ export function createIPCEvents(
},
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
- isInternalUser: () => RemoteConfig.isEnabled('desktop.internalUser'),
+ isInternalUser: () => isSettingsInternalEnabled(),
syncRequest: async () => {
const contactSyncComplete = waitForEvent(
'contactSync:complete',
@@ -552,6 +555,9 @@ export function createIPCEvents(
return contactSyncComplete;
},
// Only for internal use
+ exportLocalBackup: () => backupsService._internalExportLocalBackup(),
+ importLocalBackup: () =>
+ backupsService._internalStageLocalBackupForImport(),
validateBackup: () => backupsService._internalValidate(),
getLastSyncTime: () => window.storage.get('synced_at'),
setLastSyncTime: value => window.storage.put('synced_at', value),
diff --git a/ts/util/downloadAttachment.ts b/ts/util/downloadAttachment.ts
index 671195cc62..497fbf62c5 100644
--- a/ts/util/downloadAttachment.ts
+++ b/ts/util/downloadAttachment.ts
@@ -7,8 +7,10 @@ import {
AttachmentVariant,
AttachmentPermanentlyUndownloadableError,
getAttachmentIdForLogging,
+ mightBeInLocalBackup,
} from '../types/Attachment';
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
+import { downloadAttachmentFromLocalBackup as doDownloadAttachmentFromLocalBackup } from './downloadAttachmentFromLocalBackup';
import { MediaTier } from '../types/AttachmentDownload';
import * as log from '../logging/log';
import { HTTPError } from '../textsecure/Errors';
@@ -18,7 +20,10 @@ import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto';
export async function downloadAttachment({
attachment,
options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
- dependencies = { downloadAttachmentFromServer: doDownloadAttachment },
+ dependencies = {
+ downloadAttachmentFromServer: doDownloadAttachment,
+ downloadAttachmentFromLocalBackup: doDownloadAttachmentFromLocalBackup,
+ },
}: {
attachment: AttachmentType;
options: {
@@ -26,7 +31,10 @@ export async function downloadAttachment({
onSizeUpdate: (totalBytes: number) => void;
abortSignal: AbortSignal;
};
- dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment };
+ dependencies?: {
+ downloadAttachmentFromServer: typeof doDownloadAttachment;
+ downloadAttachmentFromLocalBackup: typeof doDownloadAttachmentFromLocalBackup;
+ };
}): Promise {
const attachmentId = getAttachmentIdForLogging(attachment);
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)) {
try {
return await dependencies.downloadAttachmentFromServer(
diff --git a/ts/util/downloadAttachmentFromLocalBackup.ts b/ts/util/downloadAttachmentFromLocalBackup.ts
new file mode 100644
index 0000000000..c0aff5eb13
--- /dev/null
+++ b/ts/util/downloadAttachmentFromLocalBackup.ts
@@ -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 {
+ 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 {
+ 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,
+ });
+}
diff --git a/ts/util/isLocalBackupsEnabled.ts b/ts/util/isLocalBackupsEnabled.ts
new file mode 100644
index 0000000000..89d28644c7
--- /dev/null
+++ b/ts/util/isLocalBackupsEnabled.ts
@@ -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;
+}
diff --git a/ts/util/isSettingsInternalEnabled.ts b/ts/util/isSettingsInternalEnabled.ts
new file mode 100644
index 0000000000..eb881e4cdb
--- /dev/null
+++ b/ts/util/isSettingsInternalEnabled.ts
@@ -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;
+}
diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts
index 6e55218282..0412c537ee 100644
--- a/ts/windows/preload.ts
+++ b/ts/windows/preload.ts
@@ -51,6 +51,8 @@ installCallback('isInternalUser');
installCallback('syncRequest');
installCallback('getEmojiSkinToneDefault');
installCallback('setEmojiSkinToneDefault');
+installCallback('exportLocalBackup');
+installCallback('importLocalBackup');
installCallback('validateBackup');
installSetting('alwaysRelayCalls');
diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx
index 61a9ba9bd2..b7bb1f28d6 100644
--- a/ts/windows/settings/app.tsx
+++ b/ts/windows/settings/app.tsx
@@ -43,6 +43,7 @@ SettingsWindowProps.onRender(
doDeleteAllData,
doneRendering,
editCustomColor,
+ exportLocalBackup,
getConversationsWithCustomColor,
hasAudioNotifications,
hasAutoConvertEmoji,
@@ -67,6 +68,7 @@ SettingsWindowProps.onRender(
hasStoriesDisabled,
hasTextFormatting,
hasTypingIndicators,
+ importLocalBackup,
initialSpellCheckSetting,
isAutoDownloadUpdatesSupported,
isAutoLaunchSupported,
@@ -152,6 +154,7 @@ SettingsWindowProps.onRender(
defaultConversationColor={defaultConversationColor}
deviceName={deviceName}
emojiSkinToneDefault={emojiSkinToneDefault}
+ exportLocalBackup={exportLocalBackup}
phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData}
doneRendering={doneRendering}
@@ -181,6 +184,7 @@ SettingsWindowProps.onRender(
hasTextFormatting={hasTextFormatting}
hasTypingIndicators={hasTypingIndicators}
i18n={i18n}
+ importLocalBackup={importLocalBackup}
initialSpellCheckSetting={initialSpellCheckSetting}
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
isAutoLaunchSupported={isAutoLaunchSupported}
diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts
index cbc606801a..9ac4894606 100644
--- a/ts/windows/settings/preload.ts
+++ b/ts/windows/settings/preload.ts
@@ -100,6 +100,8 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcIsInternalUser = createCallback('isInternalUser');
const ipcMakeSyncRequest = createCallback('syncRequest');
+const ipcExportLocalBackup = createCallback('exportLocalBackup');
+const ipcImportLocalBackup = createCallback('importLocalBackup');
const ipcValidateBackup = createCallback('validateBackup');
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
@@ -369,6 +371,8 @@ async function renderPreferences() {
resetAllChatColors: ipcResetAllChatColors,
resetDefaultChatColor: ipcResetDefaultChatColor,
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
+ exportLocalBackup: ipcExportLocalBackup,
+ importLocalBackup: ipcImportLocalBackup,
validateBackup: ipcValidateBackup,
// Limited support features
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(