// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; import path from 'path'; import { tmpdir } from 'os'; import { omit, sortBy } from 'lodash'; import { createReadStream } from 'fs'; import { mkdtemp, rm } from 'fs/promises'; import * as sinon from 'sinon'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys'; import type { EditHistoryType, MessageAttributesType, MessageReactionType, } from '../../model-types'; import type { SendStateByConversationId, SendState, } from '../../messages/MessageSendState'; import { backupsService } from '../../services/backups'; import { isUnsupportedMessage } from '../../state/selectors/message'; import { generateAci, generatePni } from '../../types/ServiceId'; import { DataReader, DataWriter } from '../../sql/Client'; import { getRandomBytes } from '../../Crypto'; import * as Bytes from '../../Bytes'; export const OUR_ACI = generateAci(); export const OUR_PNI = generatePni(); export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32)); export const PROFILE_KEY = getRandomBytes(32); export const ACCOUNT_ENTROPY_POOL = AccountEntropyPool.generate(); export const MEDIA_ROOT_KEY = getRandomBytes(32); // This is preserved across data erasure const CONVO_ID_TO_STABLE_ID = new Map(); function mapConvoId(id?: string | null): string | undefined { if (id == null) { return undefined; } return CONVO_ID_TO_STABLE_ID.get(id) ?? id; } type MessageAttributesForComparisonType = Omit< MessageAttributesType, 'id' | 'received_at' | 'editHistory' | 'reactions' | 'conversationId' > & { conversationId: string | undefined; editHistory?: Array>; reactions?: Array>; }; // We need to eliminate fields that won't stay stable through import/export function sortAndNormalize( messages: Array ): Array { return sortBy(messages, 'sent_at').map(message => { const { changedId, conversationId, editHistory, key_changed: keyChanged, reactions, sendStateByConversationId, verifiedChanged, attachments, preview, contact, quote, sticker, // This is not in the backup // eslint-disable-next-line @typescript-eslint/no-unused-vars id: _id, // eslint-disable-next-line @typescript-eslint/no-unused-vars received_at: _receivedAt, // eslint-disable-next-line @typescript-eslint/no-unused-vars sourceDevice: _sourceDevice, // eslint-disable-next-line @typescript-eslint/no-unused-vars editMessageReceivedAt: _editMessageReceivedAt, // eslint-disable-next-line @typescript-eslint/no-unused-vars schemaVersion: _schemaVersion, ...rest } = message; function mapSendState( sendState?: SendStateByConversationId ): SendStateByConversationId | undefined { if (sendState == null) { return undefined; } const result: Record = {}; for (const [id, state] of Object.entries(sendState)) { result[mapConvoId(id) ?? id] = state; } return result; } // Get rid of unserializable `undefined` values. return JSON.parse( JSON.stringify({ ...rest, conversationId: mapConvoId(conversationId), reactions: reactions?.map(({ fromId, ...restOfReaction }) => { return { from: mapConvoId(fromId), ...restOfReaction, }; }), changedId: mapConvoId(changedId), key_changed: mapConvoId(keyChanged), verifiedChanged: mapConvoId(verifiedChanged), sendStateByConverationId: mapSendState(sendStateByConversationId), editHistory: editHistory?.map(history => { const { sendStateByConversationId: historySendState, // eslint-disable-next-line @typescript-eslint/no-unused-vars received_at: _receivedAtHistory, ...restOfHistory } = history; return { ...restOfHistory, sendStateByConversationId: mapSendState(historySendState), }; }), attachments: attachments?.map(attachment => omit(attachment, 'downloadPath') ), preview: preview?.map(previewItem => ({ ...previewItem, image: omit(previewItem.image, 'downloadPath'), })), contact: contact?.map(contactItem => ({ ...contactItem, avatar: { ...contactItem.avatar, avatar: omit(contactItem.avatar?.avatar, 'downloadPath'), }, })), quote: quote ? { ...quote, attachments: quote?.attachments.map(quotedAttachment => ({ ...quotedAttachment, thumbnail: omit(quotedAttachment.thumbnail, 'downloadPath'), })), } : undefined, sticker: sticker ? { ...sticker, data: omit(sticker.data, 'downloadPath'), } : undefined, // Not an original property, but useful isUnsupported: isUnsupportedMessage(message), }) ); }); } type HarnessOptionsType = { backupLevel: BackupLevel; comparator?: ( msgBefore: MessageAttributesForComparisonType, msgAfter: MessageAttributesForComparisonType ) => void; }; export async function symmetricRoundtripHarness( messages: Array, options: HarnessOptionsType = { backupLevel: BackupLevel.Free } ): Promise { return asymmetricRoundtripHarness(messages, messages, options); } async function updateConvoIdToTitle() { const all = await DataReader.getAllConversations(); for (const convo of all) { CONVO_ID_TO_STABLE_ID.set( convo.id, convo.serviceId ?? convo.e164 ?? convo.masterKey ?? convo.id ); } } export async function asymmetricRoundtripHarness( before: Array, after: Array, options: HarnessOptionsType = { backupLevel: BackupLevel.Free } ): Promise { const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); const fetchAndSaveBackupCdnObjectMetadata = sinon.stub( backupsService, 'fetchAndSaveBackupCdnObjectMetadata' ); try { const targetOutputFile = path.join(outDir, 'backup.bin'); await DataWriter.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); await backupsService.exportToDisk(targetOutputFile, options.backupLevel); await updateConvoIdToTitle(); await clearData(); await backupsService.importBackup(() => createReadStream(targetOutputFile)); const messagesFromDatabase = await DataReader._getAllMessages(); await updateConvoIdToTitle(); const expected = sortAndNormalize(after); const actual = sortAndNormalize(messagesFromDatabase); if (options.comparator) { assert.strictEqual(actual.length, expected.length); for (let i = 0; i < actual.length; i += 1) { options.comparator(expected[i], actual[i]); } } else { assert.deepEqual(actual, expected); } } finally { fetchAndSaveBackupCdnObjectMetadata.restore(); await rm(outDir, { recursive: true }); } } export async function clearData(): Promise { await DataWriter.removeAll(); await window.storage.fetch(); window.ConversationController.reset(); await setupBasics(); } export async function setupBasics(): Promise { await window.storage.put('uuid_id', `${OUR_ACI}.2`); await window.storage.put('pni', OUR_PNI); await window.storage.put('masterKey', MASTER_KEY); await window.storage.put('accountEntropyPool', ACCOUNT_ENTROPY_POOL); await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY); await window.storage.put('profileKey', PROFILE_KEY); await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { pni: OUR_PNI, systemGivenName: 'ME', profileKey: Bytes.toBase64(PROFILE_KEY), }); window.Events = { ...window.Events, getTypingIndicatorSetting: () => window.storage.get('typingIndicators', false), getLinkPreviewSetting: () => window.storage.get('linkPreviews', false), }; }