From 03406b15fa567447903298dc5b52cf93124a27d4 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 11 Jun 2024 17:22:54 -0400 Subject: [PATCH] Backup support for sticker messages --- ts/services/backups/util/filePointers.ts | 35 ++- ts/state/selectors/message.ts | 14 +- ts/test-electron/backup/attachments_test.ts | 287 +++++++++++++++++++- ts/test-electron/backup/filePointer_test.ts | 37 ++- ts/test-electron/backup/helpers.ts | 47 +++- 5 files changed, 381 insertions(+), 39 deletions(-) diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 507f8edd8..52ea07883 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -37,6 +37,7 @@ import { } from './mediaId'; import { redactGenericText } from '../../../util/privacy'; import { missingCaseError } from '../../../util/missingCaseError'; +import { toLogFormat } from '../../../types/errors'; export function convertFilePointerToAttachment( filePointer: Backups.FilePointer @@ -225,6 +226,16 @@ export async function getFilePointerForAttachment({ attachment.digest ?? '' )})`; + if (attachment.size == null) { + log.warn(`${logId}: attachment had nullish size, dropping`); + return { + filePointer: new Backups.FilePointer({ + ...filePointerRootProps, + invalidAttachmentLocator: getInvalidAttachmentLocator(), + }), + }; + } + if (!isAttachmentLocallySaved(attachment)) { // 1. If the attachment is undownloaded, we cannot trust its digest / mediaName. Thus, // we only include a BackupLocator if this attachment already had one (e.g. we @@ -290,7 +301,7 @@ export async function getFilePointerForAttachment({ } // Some attachments (e.g. those quoted ones copied from the original message) may not - // have any encryption info, including a digest! + // have any encryption info, including a digest. if (attachment.digest) { // From here on, this attachment is headed to (or already on) the backup tier! const mediaNameForCurrentVersionOfAttachment = @@ -326,9 +337,25 @@ export async function getFilePointerForAttachment({ } } - log.info(`${logId}: Generating new encryption info for attachment`); - const attachmentWithNewEncryptionInfo = - await generateNewEncryptionInfoForAttachment(attachment); + let attachmentWithNewEncryptionInfo: AttachmentReadyForBackup | undefined; + try { + log.info(`${logId}: Generating new encryption info for attachment`); + attachmentWithNewEncryptionInfo = + await generateNewEncryptionInfoForAttachment(attachment); + } catch (e) { + log.error( + `${logId}: Error when generating new encryption info for attachment`, + toLogFormat(e) + ); + + return { + filePointer: new Backups.FilePointer({ + ...filePointerRootProps, + invalidAttachmentLocator: getInvalidAttachmentLocator(), + }), + }; + } + return { filePointer: new Backups.FilePointer({ ...filePointerRootProps, diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 3eb0b4316..27f1afb6e 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -59,7 +59,11 @@ import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getMentionsRegex } from '../../types/Message'; import { SignalService as Proto } from '../../protobuf'; import type { AttachmentType } from '../../types/Attachment'; -import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment'; +import { + isVoiceMessage, + canBeDownloaded, + defaultBlurHash, +} from '../../types/Attachment'; import { type DefaultConversationColorType } from '../../types/Colors'; import { ReadStatus } from '../../messages/MessageReadStatus'; @@ -301,16 +305,14 @@ export const getAttachmentsForMessage = ({ if (sticker && sticker.data) { const { data } = sticker; - // We don't show anything if we don't have the sticker or the blurhash... - if (!data.blurHash && (data.pending || !data.path)) { - return []; - } - return [ { ...data, // We want to show the blurhash for stickers, not the spinner pending: false, + // Stickers are not guaranteed to have a blurhash (e.g. if imported but + // undownloaded from backup), so we want to make sure we have something to show + blurHash: data.blurHash ?? defaultBlurHash(), url: data.path ? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path) : undefined, diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index 9b394a661..9a1368b3a 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -4,6 +4,9 @@ import { v4 as generateGuid } from 'uuid'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import { omit } from 'lodash'; +import * as sinon from 'sinon'; +import { join } from 'path'; +import { assert } from 'chai'; import type { ConversationModel } from '../../models/conversations'; import * as Bytes from '../../Bytes'; @@ -13,7 +16,13 @@ import { ReadStatus } from '../../messages/MessageReadStatus'; import { SeenStatus } from '../../MessageSeenStatus'; import { loadCallsHistory } from '../../services/callHistoryLoader'; import { setupBasics, asymmetricRoundtripHarness } from './helpers'; -import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME'; +import { + AUDIO_MP3, + IMAGE_JPEG, + IMAGE_PNG, + IMAGE_WEBP, + VIDEO_MP4, +} from '../../types/MIME'; import type { MessageAttributesType, QuotedMessageType, @@ -21,10 +30,12 @@ import type { import { isVoiceMessage, type AttachmentType } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import { SignalService } from '../../protobuf'; +import { getRandomBytes } from '../../Crypto'; const CONTACT_A = generateAci(); describe('backup/attachments', () => { + let sandbox: sinon.SinonSandbox; let contactA: ConversationModel; beforeEach(async () => { @@ -41,6 +52,25 @@ describe('backup/attachments', () => { ); await loadCallsHistory(); + + sandbox = sinon.createSandbox(); + const getAbsoluteAttachmentPath = sandbox.stub( + window.Signal.Migrations, + 'getAbsoluteAttachmentPath' + ); + getAbsoluteAttachmentPath.callsFake(path => { + if (path === 'path/to/sticker') { + return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg'); + } + if (path === 'path/to/thumbnail') { + return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg'); + } + return getAbsoluteAttachmentPath.wrappedMethod(path); + }); + }); + + afterEach(() => { + sandbox.restore(); }); function getBase64(str: string): string { @@ -66,7 +96,7 @@ describe('backup/attachments', () => { width: 150, height: 150, contentType: IMAGE_PNG, - path: '/path/to/thumbnail.png', + path: 'path/to/thumbnail', }, ...overrides, }; @@ -112,7 +142,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Messages + { backupLevel: BackupLevel.Messages } ); }); it('BackupLevel.Media, roundtrips normal attachments', async () => { @@ -142,7 +172,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } ); }); it('roundtrips voice message attachments', async () => { @@ -174,7 +204,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } ); }); }); @@ -201,7 +231,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Messages + { backupLevel: BackupLevel.Messages } ); }); it('BackupLevel.Media, roundtrips preview attachments', async () => { @@ -245,7 +275,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } ); }); }); @@ -273,7 +303,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Messages + { backupLevel: BackupLevel.Messages } ); }); it('BackupLevel.Media, roundtrips contact attachments', async () => { @@ -308,7 +338,7 @@ describe('backup/attachments', () => { ], }), ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } ); }); }); @@ -348,7 +378,7 @@ describe('backup/attachments', () => { }, }), ], - BackupLevel.Messages + { backupLevel: BackupLevel.Messages } ); }); it('BackupLevel.Media, roundtrips quote attachments', async () => { @@ -393,7 +423,7 @@ describe('backup/attachments', () => { }, }), ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } ); }); @@ -459,8 +489,241 @@ describe('backup/attachments', () => { }, }, ], - BackupLevel.Media + { backupLevel: BackupLevel.Media } + ); + }); + + it('handles quotes which have been copied over from the original (and lack all encryption info)', async () => { + const originalMessage = composeMessage(1); + const quotedMessage: QuotedMessageType = { + authorAci: originalMessage.sourceServiceId as AciString, + isViewOnce: false, + id: originalMessage.timestamp, + referencedMessageNotFound: false, + messageId: '', + isGiftBadge: false, + attachments: [ + { + thumbnail: { + contentType: IMAGE_PNG, + size: 100, + path: 'path/to/thumbnail', + }, + contentType: VIDEO_MP4, + }, + ], + }; + + const quoteMessage = composeMessage(originalMessage.timestamp + 1, { + quote: quotedMessage, + }); + + await asymmetricRoundtripHarness( + [originalMessage, quoteMessage], + [ + originalMessage, + { + ...quoteMessage, + quote: { + ...quotedMessage, + referencedMessageNotFound: false, + attachments: [ + { + // will do custom comparison for thumbnail below + contentType: VIDEO_MP4, + }, + ], + }, + }, + ], + { + backupLevel: BackupLevel.Media, + comparator: (msgBefore, msgAfter) => { + if (msgBefore.timestamp === originalMessage.timestamp) { + return assert.deepStrictEqual(msgBefore, msgAfter); + } + + const thumbnail = msgAfter.quote?.attachments[0]?.thumbnail; + strictAssert(thumbnail, 'quote thumbnail exists'); + + assert.deepStrictEqual( + omit(msgBefore, 'quote.attachments[0].thumbnail'), + omit(msgAfter, 'quote.attachments[0].thumbnail') + ); + + const { key, digest } = thumbnail; + strictAssert(digest, 'quote digest was created'); + strictAssert(key, 'quote digest was created'); + + assert.deepStrictEqual(thumbnail, { + contentType: IMAGE_PNG, + size: 100, + key, + digest, + backupLocator: { + mediaName: digest, + }, + }); + }, + } ); }); }); + + describe('sticker attachments', () => { + const packId = Bytes.toHex(getRandomBytes(16)); + const packKey = Bytes.toBase64(getRandomBytes(32)); + + describe('when copied over from sticker pack (i.e. missing encryption info)', () => { + it('BackupLevel.Media, generates new encryption info', async () => { + await asymmetricRoundtripHarness( + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: { + contentType: IMAGE_WEBP, + path: 'path/to/sticker', + size: 5322, + width: 512, + height: 512, + }, + }, + }), + ], + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: { + contentType: IMAGE_WEBP, + size: 5322, + width: 512, + height: 512, + }, + }, + }), + ], + { + backupLevel: BackupLevel.Media, + comparator: (msgBefore, msgAfter) => { + assert.deepStrictEqual( + omit(msgBefore, 'sticker.data'), + omit(msgAfter, 'sticker.data') + ); + strictAssert(msgAfter.sticker?.data, 'sticker data exists'); + + const { key, digest } = msgAfter.sticker.data; + strictAssert(digest, 'sticker digest was created'); + + assert.equal(Bytes.fromBase64(digest ?? '').byteLength, 32); + assert.equal(Bytes.fromBase64(key ?? '').byteLength, 64); + + assert.deepStrictEqual(msgAfter.sticker.data, { + contentType: IMAGE_WEBP, + size: 5322, + width: 512, + height: 512, + key, + digest, + backupLocator: { + mediaName: digest, + }, + }); + }, + } + ); + }); + it('BackupLevel.Messages, generates invalid attachment locator', async () => { + // since we aren't re-uploading with new encryption info, we can't include this + // attachment in the backup proto + await asymmetricRoundtripHarness( + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: { + contentType: IMAGE_WEBP, + path: 'path/to/sticker', + size: 5322, + width: 512, + height: 512, + }, + }, + }), + ], + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: { + contentType: IMAGE_WEBP, + size: 0, + error: true, + height: 512, + width: 512, + }, + }, + }), + ], + { + backupLevel: BackupLevel.Messages, + } + ); + }); + }); + describe('when this device sent sticker (i.e. encryption info exists on message)', () => { + it('roundtrips sticker', async () => { + const attachment = composeAttachment(1); + strictAssert(attachment.digest, 'digest exists'); + await asymmetricRoundtripHarness( + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: attachment, + }, + }), + ], + [ + composeMessage(1, { + sticker: { + emoji: '🐒', + packId, + packKey, + stickerId: 0, + data: { + ...omit(attachment, [ + 'iv', + 'path', + 'thumbnail', + 'uploadTimestamp', + ]), + backupLocator: { mediaName: attachment.digest }, + }, + }, + }), + ], + { + backupLevel: BackupLevel.Media, + } + ); + }); + }); + }); }); diff --git a/ts/test-electron/backup/filePointer_test.ts b/ts/test-electron/backup/filePointer_test.ts index dab2871d8..93b639a19 100644 --- a/ts/test-electron/backup/filePointer_test.ts +++ b/ts/test-electron/backup/filePointer_test.ts @@ -16,6 +16,7 @@ import * as Bytes from '../../Bytes'; import type { AttachmentType } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId'; +import { MASTER_KEY } from './helpers'; describe('convertFilePointerToAttachment', () => { it('processes filepointer with attachmentLocator', () => { @@ -270,6 +271,22 @@ const notInBackupCdn: GetBackupCdnInfoType = async () => { }; describe('getFilePointerForAttachment', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(window.storage, 'get').callsFake(key => { + if (key === 'masterKey') { + return MASTER_KEY; + } + return undefined; + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('not downloaded locally', () => { const undownloadedAttachment = composeAttachment({ path: undefined }); it('returns invalidAttachmentLocator if missing critical decryption info', async () => { @@ -402,11 +419,7 @@ describe('getFilePointerForAttachment', () => { describe('BackupLevel.Media', () => { describe('if missing critical decryption / encryption info', () => { const FILE_PATH = join(__dirname, '../../../fixtures/ghost-kitty.mp4'); - - let sandbox: sinon.SinonSandbox; - beforeEach(() => { - sandbox = sinon.createSandbox(); sandbox .stub(window.Signal.Migrations, 'getAbsoluteAttachmentPath') .callsFake(relPath => { @@ -417,10 +430,6 @@ describe('getFilePointerForAttachment', () => { }); }); - afterEach(() => { - sandbox.restore(); - }); - it('if missing key, generates new key & digest and removes existing CDN info', async () => { const { filePointer: result } = await getFilePointerForAttachment({ attachment: { @@ -450,6 +459,18 @@ describe('getFilePointerForAttachment', () => { ); }); + it('if file does not exist at local path, returns invalid attachment locator', async () => { + await testAttachmentToFilePointer( + { + ...downloadedAttachment, + path: 'no/file/here.png', + key: undefined, + }, + filePointerWithInvalidLocator, + { backupLevel: BackupLevel.Media } + ); + }); + it('if not on backup tier, and missing iv, regenerates encryption info', async () => { const { filePointer: result } = await getFilePointerForAttachment({ attachment: { diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts index fd4095cbb..5ea83d993 100644 --- a/ts/test-electron/backup/helpers.ts +++ b/ts/test-electron/backup/helpers.ts @@ -10,7 +10,11 @@ import { mkdtemp, rm } from 'fs/promises'; import * as sinon from 'sinon'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; -import type { MessageAttributesType } from '../../model-types'; +import type { + EditHistoryType, + MessageAttributesType, + MessageReactionType, +} from '../../model-types'; import type { SendStateByConversationId, SendState, @@ -31,18 +35,27 @@ export const PROFILE_KEY = getRandomBytes(32); // This is preserved across data erasure const CONVO_ID_TO_STABLE_ID = new Map(); -function mapConvoId(id?: string | null): string | undefined | null { +function mapConvoId(id?: string | null): string | undefined { if (id == null) { - return id; + 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 { +): Array { return sortBy(messages, 'sent_at').map(message => { const { changedId, @@ -113,11 +126,19 @@ function sortAndNormalize( }); } +type HarnessOptionsType = { + backupLevel: BackupLevel; + comparator?: ( + msgBefore: MessageAttributesForComparisonType, + msgAfter: MessageAttributesForComparisonType + ) => void; +}; + export async function symmetricRoundtripHarness( messages: Array, - backupLevel: BackupLevel = BackupLevel.Messages + options: HarnessOptionsType = { backupLevel: BackupLevel.Messages } ): Promise { - return asymmetricRoundtripHarness(messages, messages, backupLevel); + return asymmetricRoundtripHarness(messages, messages, options); } async function updateConvoIdToTitle() { @@ -133,7 +154,7 @@ async function updateConvoIdToTitle() { export async function asymmetricRoundtripHarness( before: Array, after: Array, - backupLevel: BackupLevel = BackupLevel.Messages + options: HarnessOptionsType = { backupLevel: BackupLevel.Messages } ): Promise { const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); const fetchAndSaveBackupCdnObjectMetadata = sinon.stub( @@ -145,7 +166,7 @@ export async function asymmetricRoundtripHarness( await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); - await backupsService.exportToDisk(targetOutputFile, backupLevel); + await backupsService.exportToDisk(targetOutputFile, options.backupLevel); await updateConvoIdToTitle(); @@ -159,7 +180,15 @@ export async function asymmetricRoundtripHarness( const expected = sortAndNormalize(after); const actual = sortAndNormalize(messagesFromDatabase); - assert.deepEqual(actual, expected); + + 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 });