Add backup validation to settings

This commit is contained in:
Fedor Indutny
2025-04-15 16:04:30 -07:00
committed by GitHub
parent fef6706a75
commit f68ef019a5
16 changed files with 292 additions and 34 deletions

View File

@@ -6600,6 +6600,18 @@
"messageformat": "Backups",
"description": "Button to switch the settings view to control message & media backups"
},
"icu:Preferences__button--internal": {
"messageformat": "Internal",
"description": "Button to switch the settings view to control internal configuration"
},
"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"
},
"icu:Preferences__internal__validate-backup": {
"messageformat": "Validate",
"description": "Button to run internal backup validation tool"
},
"icu:Preferences--lastSynced": {
"messageformat": "Last import at {date} {time}",
"description": "Label for date and time of last sync operation"

View File

@@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 5.938a4.063 4.063 0 1 0 0 8.125 4.063 4.063 0 0 0 0-8.126ZM7.396 10a2.604 2.604 0 1 1 5.208 0 2.604 2.604 0 0 1-5.208 0Z" fill="#000"/><path d="M.98 9.01a1.98 1.98 0 0 0 0 1.98l3.653 6.327a1.98 1.98 0 0 0 1.714.99h7.306c.707 0 1.36-.377 1.714-.99l3.653-6.327a1.979 1.979 0 0 0 0-1.98l-3.653-6.327a1.98 1.98 0 0 0-1.714-.99H6.347a1.98 1.98 0 0 0-1.714.99L.979 9.01Zm1.262 1.25a.52.52 0 0 1 0-.52l3.654-6.328a.52.52 0 0 1 .45-.26h7.307a.52.52 0 0 1 .451.26l3.654 6.328a.521.521 0 0 1 0 .52l-3.654 6.328a.52.52 0 0 1-.45.26H6.346a.52.52 0 0 1-.451-.26L2.242 10.26Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -120,6 +120,10 @@ $secondary-text-color: light-dark(
&--backups {
@include preferences-icon('../images/icons/v3/backup/backup-bold.svg');
}
&--internal {
@include preferences-icon('../images/icons/v3/internal/internal.svg');
}
}
&__settings-pane {
@@ -539,3 +543,20 @@ $secondary-text-color: light-dark(
}
}
}
.Preferences--internal--validate-backup--result {
padding-inline: 48px 24px;
}
.Preferences--internal--validate-backup--error {
padding-inline: 48px 24px;
color: variables.$color-accent-red;
}
.Preferences--internal--validate-backup--result pre,
.Preferences--internal--validate-backup--error pre {
max-height: 128px;
max-width: 100%;
white-space: pre-wrap;
user-select: text;
}

View File

@@ -113,6 +113,7 @@ export default {
isNotificationAttentionSupported: true,
isSyncSupported: true,
isSystemTraySupported: true,
isInternalUser: false,
isMinimizeToAndStartInSystemTraySupported: true,
lastSyncTime: Date.now(),
localeOverride: null,
@@ -189,6 +190,22 @@ export default {
setGlobalDefaultConversationColor: action(
'setGlobalDefaultConversationColor'
),
validateBackup: async () => {
return {
totalBytes: 100,
stats: {
adHocCalls: 1,
callLinks: 2,
conversations: 3,
chats: 4,
distributionLists: 5,
messages: 6,
skippedMessages: 7,
stickerPacks: 8,
fixedDirectMessages: 9,
},
};
},
} satisfies PropsType,
} satisfies Meta<PropsType>;
@@ -300,3 +317,9 @@ BackupsSubscriptionExpired.args = {
status: 'expired',
},
};
export const Internal = Template.bind({});
Internal.args = {
initialPage: Page.Internal,
isInternalUser: true,
};

View File

@@ -14,6 +14,7 @@ import classNames from 'classnames';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
import type { MediaDeviceSettings } from '../types/Calling';
import type { ExportResultType as BackupExportResultType } from '../services/backups';
import type {
AutoDownloadAttachmentType,
NotificationSettingType,
@@ -75,6 +76,7 @@ import {
SettingsRow,
} from './PreferencesUtil';
import { PreferencesBackups } from './PreferencesBackups';
import { PreferencesInternal } from './PreferencesInternal';
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
@@ -145,6 +147,7 @@ export type PropsDataType = {
isSyncSupported: boolean;
isSystemTraySupported: boolean;
isMinimizeToAndStartInSystemTraySupported: boolean;
isInternalUser: boolean;
availableCameras: Array<
Pick<MediaDeviceInfo, 'deviceId' | 'groupId' | 'kind' | 'label'>
@@ -175,6 +178,7 @@ type PropsFunctionType = {
value: CustomColorType;
}
) => unknown;
validateBackup: () => Promise<BackupExportResultType>;
// Change handlers
onAudioNotificationsChange: CheckboxChangeHandlerType;
@@ -232,6 +236,7 @@ export enum Page {
Privacy = 'Privacy',
DataUsage = 'DataUsage',
Backups = 'Backups',
Internal = 'Internal',
// Sub pages
ChatColor = 'ChatColor',
@@ -319,6 +324,7 @@ export function Preferences({
isSyncSupported,
isSystemTraySupported,
isMinimizeToAndStartInSystemTraySupported,
isInternalUser,
lastSyncTime,
makeSyncRequest,
notificationContent,
@@ -373,6 +379,7 @@ export function Preferences({
localeOverride,
themeSetting,
universalExpireTimer = DurationInSeconds.ZERO,
validateBackup,
whoCanFindMe,
whoCanSeeMe,
zoomFactor,
@@ -410,6 +417,9 @@ export function Preferences({
if (page === Page.Backups && !shouldShowBackupsPage) {
setPage(Page.General);
}
if (page === Page.Internal && !isInternalUser) {
setPage(Page.General);
}
useEffect(() => {
if (page === Page.Backups) {
@@ -1728,6 +1738,10 @@ export function Preferences({
locale={resolvedLocale}
/>
);
} else if (page === Page.Internal) {
settings = (
<PreferencesInternal i18n={i18n} validateBackup={validateBackup} />
);
}
return (
@@ -1829,6 +1843,19 @@ export function Preferences({
{i18n('icu:Preferences__button--backups')}
</button>
) : null}
{isInternalUser ? (
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--internal': true,
'Preferences__button--selected': page === Page.Internal,
})}
onClick={() => setPage(Page.Internal)}
>
{i18n('icu:Preferences__button--internal')}
</button>
) : null}
</div>
<div className="Preferences__settings-pane" ref={settingsPaneRef}>
{settings}

View File

@@ -0,0 +1,104 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../types/I18N';
import { toLogFormat } from '../types/errors';
import { formatFileSize } from '../util/formatFileSize';
import type { ExportResultType as BackupExportResultType } from '../services/backups';
import { SettingsRow, SettingsControl } from './PreferencesUtil';
import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner';
export function PreferencesInternal({
i18n,
validateBackup: doValidateBackup,
}: {
i18n: LocalizerType;
validateBackup: () => Promise<BackupExportResultType>;
}): JSX.Element {
const [isValidationPending, setIsValidationPending] = useState(false);
const [validationResult, setValidationResult] = useState<
| {
result: BackupExportResultType;
}
| {
error: Error;
}
| undefined
>();
const validateBackup = useCallback(async () => {
setIsValidationPending(true);
setValidationResult(undefined);
try {
setValidationResult({ result: await doValidateBackup() });
} catch (error) {
setValidationResult({ error });
} finally {
setIsValidationPending(false);
}
}, [doValidateBackup]);
let validationElem: JSX.Element | undefined;
if (validationResult != null) {
if ('result' in validationResult) {
const {
result: { totalBytes, stats },
} = validationResult;
validationElem = (
<div className="Preferences--internal--validate-backup--result">
<p>File size: {formatFileSize(totalBytes)}</p>
<pre>
<code>{JSON.stringify(stats, null, 2)}</code>
</pre>
</div>
);
} else {
const { error } = validationResult;
validationElem = (
<div className="Preferences--internal--validate-backup--error">
<pre>
<code>{toLogFormat(error)}</code>
</pre>
</div>
);
}
}
return (
<>
<div className="Preferences__title Preferences__title--internal">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--internal')}
</div>
</div>
<SettingsRow
className="Preferences--internal--backups"
title={i18n('icu:Preferences__button--backups')}
>
<SettingsControl
left={i18n('icu:Preferences__internal__validate-backup--description')}
right={
<Button
variant={ButtonVariant.Secondary}
onClick={validateBackup}
disabled={isValidationPending}
>
{isValidationPending ? (
<Spinner size="22px" svgSize="small" />
) : (
i18n('icu:Preferences__internal__validate-backup')
)}
</Button>
}
/>
{validationElem}
</SettingsRow>
</>
);
}

View File

@@ -64,9 +64,11 @@ export class SettingsChannel extends EventEmitter {
this.#installCallback('deleteAllMyStories');
this.#installCallback('getAvailableIODevices');
this.#installCallback('isPrimary');
this.#installCallback('isInternalUser');
this.#installCallback('syncRequest');
this.#installCallback('setEmojiSkinToneDefault');
this.#installCallback('getEmojiSkinToneDefault');
this.#installCallback('validateBackup');
// Backups
this.#installSetting('backupFeatureEnabled', { setter: false });

View File

@@ -204,6 +204,18 @@ type NonBubbleResultType = Readonly<
}
>;
export type StatsType = {
adHocCalls: number;
callLinks: number;
conversations: number;
chats: number;
distributionLists: number;
messages: number;
skippedMessages: number;
stickerPacks: number;
fixedDirectMessages: number;
};
export class BackupExportStream extends Readable {
// Shared between all methods for consistency.
#now = Date.now();
@@ -213,7 +225,7 @@ export class BackupExportStream extends Readable {
readonly #serviceIdToRecipientId = new Map<string, number>();
readonly #e164ToRecipientId = new Map<string, number>();
readonly #roomIdToRecipientId = new Map<string, number>();
readonly #stats = {
readonly #stats: StatsType = {
adHocCalls: 0,
callLinks: 0,
conversations: 0,
@@ -270,6 +282,10 @@ export class BackupExportStream extends Readable {
);
}
public getStats(): Readonly<StatsType> {
return this.#stats;
}
async #unsafeRun(backupLevel: BackupLevel): Promise<void> {
this.#ourConversation =
window.ConversationController.getOurConversationOrThrow().attributes;

View File

@@ -47,7 +47,7 @@ import { constantTimeEqual } from '../../Crypto';
import { measureSize } from '../../AttachmentCrypto';
import { isTestOrMockEnvironment } from '../../environment';
import { runStorageServiceSyncJob } from '../storage';
import { BackupExportStream } from './export';
import { BackupExportStream, type StatsType } from './export';
import { BackupImportStream } from './import';
import { getKeyMaterial } from './crypto';
import { BackupCredentials } from './credentials';
@@ -61,6 +61,8 @@ import {
BackupProcessingError,
RelinkRequestedError,
} from './errors';
import { FileStream } from './util/FileStream';
import { MemoryStream } from './util/MemoryStream';
import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version';
import { getMessageQueueTime } from '../../util/getMessageQueueTime';
@@ -96,6 +98,11 @@ export type ImportOptionsType = Readonly<{
onProgress?: (currentBytes: number, totalBytes: number) => void;
}>;
export type ExportResultType = Readonly<{
totalBytes: number;
stats: Readonly<StatsType>;
}>;
export class BackupsService {
#isStarted = false;
#isRunning: 'import' | 'export' | false = false;
@@ -289,14 +296,17 @@ export class BackupsService {
public async exportBackupData(
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<Uint8Array> {
): Promise<{ data: Uint8Array } & ExportResultType> {
const sink = new PassThrough();
const chunks = new Array<Uint8Array>();
sink.on('data', chunk => chunks.push(chunk));
await this.#exportBackup(sink, backupLevel, backupType);
const result = await this.#exportBackup(sink, backupLevel, backupType);
return Bytes.concatenate(chunks);
return {
...result,
data: Bytes.concatenate(chunks),
};
}
// Test harness
@@ -305,22 +315,38 @@ export class BackupsService {
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<number> {
const size = await this.#exportBackup(
const { totalBytes } = await this.#exportBackup(
createWriteStream(path),
backupLevel,
backupType
);
if (backupType === BackupType.Ciphertext) {
await validateBackup(path, size);
await validateBackup(() => new FileStream(path), totalBytes);
}
return size;
return totalBytes;
}
// Test harness
public async validate(
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<ExportResultType> {
const { data, ...result } = await this.exportBackupData(
backupLevel,
backupType
);
const buffer = Buffer.from(data);
await validateBackup(() => new MemoryStream(buffer), buffer.byteLength);
return result;
}
// Test harness
public async exportWithDialog(): Promise<void> {
const data = await this.exportBackupData();
const { data } = await this.exportBackupData();
const { saveAttachmentToDisk } = window.Signal.Migrations;
@@ -712,7 +738,7 @@ export class BackupsService {
sink: Writable,
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<number> {
): Promise<ExportResultType> {
strictAssert(!this.#isRunning, 'BackupService is already running');
log.info('exportBackup: starting...');
@@ -766,7 +792,7 @@ export class BackupsService {
throw missingCaseError(backupType);
}
return totalBytes;
return { totalBytes, stats: recordStream.getStats() };
} finally {
log.info('exportBackup: finished...');
this.#isRunning = false;

View File

@@ -0,0 +1,23 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { type Buffer } from 'node:buffer';
import { InputStream } from '@signalapp/libsignal-client/dist/io';
export class MemoryStream extends InputStream {
#offset = 0;
constructor(private readonly buffer: Buffer) {
super();
}
public override async read(amount: number): Promise<Buffer> {
const result = this.buffer.subarray(this.#offset, this.#offset + amount);
this.#offset += amount;
return result;
}
public override async skip(amount: number): Promise<void> {
this.#offset += amount;
}
}

View File

@@ -2,14 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as libsignal from '@signalapp/libsignal-client/dist/MessageBackup';
import type { InputStream } from '@signalapp/libsignal-client/dist/io';
import { strictAssert } from '../../util/assert';
import { toAciObject } from '../../util/ServiceId';
import { isTestOrMockEnvironment } from '../../environment';
import { FileStream } from './util/FileStream';
export async function validateBackup(
filePath: string,
inputFactory: () => InputStream,
fileSize: number
): Promise<void> {
const accountEntropy = window.storage.get('accountEntropyPool');
@@ -24,7 +24,7 @@ export async function validateBackup(
const outcome = await libsignal.validate(
backupKey,
libsignal.Purpose.RemoteBackup,
() => new FileStream(filePath),
inputFactory,
BigInt(fileSize)
);

View File

@@ -6,7 +6,6 @@ import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Readable } from 'node:stream';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { InputStream } from '@signalapp/libsignal-client/dist/io';
import {
ComparableBackup,
Purpose,
@@ -16,29 +15,12 @@ import { assert } from 'chai';
import { clearData } from './helpers';
import { loadAllAndReinitializeRedux } from '../../services/allLoaders';
import { backupsService, BackupType } from '../../services/backups';
import { MemoryStream } from '../../services/backups/util/MemoryStream';
import { initialize as initializeExpiringMessageService } from '../../services/expiringMessagesDeletion';
import { DataWriter } from '../../sql/Client';
const { BACKUP_INTEGRATION_DIR } = process.env;
class MemoryStream extends InputStream {
#offset = 0;
constructor(private readonly buffer: Buffer) {
super();
}
public override async read(amount: number): Promise<Buffer> {
const result = this.buffer.slice(this.#offset, this.#offset + amount);
this.#offset += amount;
return result;
}
public override async skip(amount: number): Promise<void> {
this.#offset += amount;
}
}
describe('backup/integration', () => {
before(async () => {
await initializeExpiringMessageService();
@@ -75,7 +57,7 @@ describe('backup/integration', () => {
backupType: BackupType.TestOnlyPlaintext,
});
const exported = await backupsService.exportBackupData(
const { data: exported } = await backupsService.exportBackupData(
BackupLevel.Paid,
BackupType.TestOnlyPlaintext
);

View File

@@ -23,6 +23,10 @@ import type { ConversationType } from '../state/ducks/conversations';
import { calling } from '../services/calling';
import { resolveUsernameByLinkBase64 } from '../services/username';
import { writeProfile } from '../services/writeProfile';
import {
backupsService,
type ExportResultType as BackupExportResultType,
} from '../services/backups';
import { isInCall } from '../state/selectors/calling';
import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations';
import { getCustomColors } from '../state/selectors/items';
@@ -65,6 +69,7 @@ import type {
BackupStatusType,
} from '../types/backups';
import { isBackupFeatureEnabled } from './isBackupEnabled';
import * as RemoteConfig from '../RemoteConfig';
type SentMediaQualityType = 'standard' | 'high';
type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
@@ -135,6 +140,7 @@ export type IPCEventsCallbacksType = {
) => Promise<ReturnType<SystemPreferences['getMediaAccessStatus']>>;
installStickerPack: (packId: string, key: string) => Promise<void>;
isPrimary: () => boolean;
isInternalUser: () => boolean;
removeCustomColor: (x: string) => void;
removeCustomColorOnConversations: (x: string) => void;
removeDarkOverlay: () => void;
@@ -158,6 +164,7 @@ export type IPCEventsCallbacksType = {
unknownSignalLink: () => void;
getCustomColors: () => Record<string, CustomColorType>;
syncRequest: () => Promise<void>;
validateBackup: () => Promise<BackupExportResultType>;
setGlobalDefaultConversationColor: (
color: ConversationColorType,
customColor?: { id: string; value: CustomColorType }
@@ -535,6 +542,7 @@ export function createIPCEvents(
},
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
isInternalUser: () => RemoteConfig.isEnabled('desktop.internalUser'),
syncRequest: async () => {
const contactSyncComplete = waitForEvent(
'contactSync:complete',
@@ -543,6 +551,7 @@ export function createIPCEvents(
await sendSyncRequests();
return contactSyncComplete;
},
validateBackup: () => backupsService.validate(),
getLastSyncTime: () => window.storage.get('synced_at'),
setLastSyncTime: value => window.storage.put('synced_at', value),
getUniversalExpireTimer: () => universalExpireTimer.get(),

View File

@@ -47,9 +47,11 @@ installCallback('refreshCloudBackupStatus');
installCallback('refreshBackupSubscriptionStatus');
installCallback('deleteAllMyStories');
installCallback('isPrimary');
installCallback('isInternalUser');
installCallback('syncRequest');
installCallback('getEmojiSkinToneDefault');
installCallback('setEmojiSkinToneDefault');
installCallback('validateBackup');
installSetting('alwaysRelayCalls');
installSetting('audioMessage');

View File

@@ -75,6 +75,7 @@ SettingsWindowProps.onRender(
isNotificationAttentionSupported,
isSyncSupported,
isSystemTraySupported,
isInternalUser,
lastSyncTime,
makeSyncRequest,
notificationContent,
@@ -128,6 +129,7 @@ SettingsWindowProps.onRender(
localeOverride,
themeSetting,
universalExpireTimer,
validateBackup,
whoCanFindMe,
whoCanSeeMe,
zoomFactor,
@@ -188,6 +190,7 @@ SettingsWindowProps.onRender(
isNotificationAttentionSupported={isNotificationAttentionSupported}
isSyncSupported={isSyncSupported}
isSystemTraySupported={isSystemTraySupported}
isInternalUser={isInternalUser}
lastSyncTime={lastSyncTime}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
@@ -243,6 +246,7 @@ SettingsWindowProps.onRender(
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
themeSetting={themeSetting}
universalExpireTimer={universalExpireTimer}
validateBackup={validateBackup}
whoCanFindMe={whoCanFindMe}
whoCanSeeMe={whoCanSeeMe}
zoomFactor={zoomFactor}

View File

@@ -98,7 +98,9 @@ const ipcGetAvailableIODevices = createCallback('getAvailableIODevices');
const ipcGetCustomColors = createCallback('getCustomColors');
const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcIsInternalUser = createCallback('isInternalUser');
const ipcMakeSyncRequest = createCallback('syncRequest');
const ipcValidateBackup = createCallback('validateBackup');
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
const ipcRefreshBackupSubscriptionStatus = createCallback(
@@ -205,6 +207,7 @@ async function renderPreferences() {
customColors,
defaultConversationColor,
isSyncNotSupported,
isInternalUser,
} = await awaitObject({
autoDownloadAttachment: settingAutoDownloadAttachment.getValue(),
backupFeatureEnabled: settingBackupFeatureEnabled.getValue(),
@@ -253,6 +256,7 @@ async function renderPreferences() {
defaultConversationColor: ipcGetDefaultConversationColor(),
emojiSkinToneDefault: ipcGetEmojiSkinToneDefault(),
isSyncNotSupported: ipcIsSyncNotSupported(),
isInternalUser: ipcIsInternalUser(),
});
const { availableCameras, availableMicrophones, availableSpeakers } =
@@ -365,6 +369,7 @@ async function renderPreferences() {
resetAllChatColors: ipcResetAllChatColors,
resetDefaultChatColor: ipcResetDefaultChatColor,
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
validateBackup: ipcValidateBackup,
// Limited support features
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(
OS,
@@ -374,6 +379,7 @@ async function renderPreferences() {
isHideMenuBarSupported: Settings.isHideMenuBarSupported(OS),
isNotificationAttentionSupported: Settings.isDrawAttentionSupported(OS),
isSyncSupported: !isSyncNotSupported,
isInternalUser,
isSystemTraySupported: Settings.isSystemTraySupported(OS),
isMinimizeToAndStartInSystemTraySupported:
Settings.isMinimizeToAndStartInSystemTraySupported(OS),