Signal-Desktop/ts/test-electron/backup/attachments_test.ts

834 lines
24 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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';
import { DataWriter } from '../../sql/Client';
import { type AciString, generateAci } from '../../types/ServiceId';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
import {
AUDIO_MP3,
IMAGE_JPEG,
IMAGE_PNG,
IMAGE_WEBP,
LONG_MESSAGE,
VIDEO_MP4,
} from '../../types/MIME';
import type {
MessageAttributesType,
QuotedMessageType,
} from '../../model-types';
import {
hasRequiredInformationForBackup,
isVoiceMessage,
type AttachmentType,
} from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import { SignalService } from '../../protobuf';
import { getRandomBytes } from '../../Crypto';
import { loadAllAndReinitializeRedux } from '../../services/allLoaders';
import {
generateAttachmentKeys,
generateKeys,
getPlaintextHashForInMemoryAttachment,
} from '../../AttachmentCrypto';
import { isValidAttachmentKey } from '../../types/Crypto';
const CONTACT_A = generateAci();
const NON_ROUNDTRIPPED_FIELDS = ['path', 'thumbnail', 'screenshot', 'localKey'];
describe('backup/attachments', () => {
let sandbox: sinon.SinonSandbox;
let contactA: ConversationModel;
beforeEach(async () => {
await DataWriter.removeAll();
window.storage.reset();
window.ConversationController.reset();
await setupBasics();
contactA = await window.ConversationController.getOrCreateAndWait(
CONTACT_A,
'private',
{ systemGivenName: 'CONTACT_A', active_at: 1 }
);
await loadAllAndReinitializeRedux();
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(async () => {
await DataWriter.removeAll();
sandbox.restore();
});
function composeAttachment(
index: number,
overrides?: Partial<AttachmentType>
): AttachmentType {
return {
cdnKey: `cdnKey${index}`,
cdnNumber: 3,
clientUuid: generateGuid(),
plaintextHash: Bytes.toHex(getRandomBytes(32)),
key: Bytes.toBase64(generateKeys()),
digest: Bytes.toBase64(getRandomBytes(32)),
size: 100,
contentType: IMAGE_JPEG,
path: `/path/to/file${index}.png`,
localKey: Bytes.toBase64(generateAttachmentKeys()),
uploadTimestamp: index,
thumbnail: {
size: 1024,
width: 150,
height: 150,
contentType: IMAGE_PNG,
path: 'path/to/thumbnail',
},
...overrides,
};
}
function composeMessage(
timestamp: number,
overrides?: Partial<MessageAttributesType>
): MessageAttributesType {
return {
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: timestamp,
received_at_ms: timestamp,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
schemaVersion: 0,
sent_at: timestamp,
timestamp,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
unidentifiedDeliveryReceived: true,
...overrides,
};
}
function expectedRoundtrippedFields(
attachment: AttachmentType
): AttachmentType {
const base = omit(attachment, NON_ROUNDTRIPPED_FIELDS);
if (hasRequiredInformationForBackup(attachment)) {
delete base.digest;
} else {
delete base.plaintextHash;
}
return base;
}
describe('long-message attachments', () => {
it('preserves attachment still on message.attachments', async () => {
const longMessageAttachment = composeAttachment(1, {
contentType: LONG_MESSAGE,
});
const normalAttachment = composeAttachment(2);
strictAssert(longMessageAttachment.digest, 'digest exists');
strictAssert(normalAttachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [longMessageAttachment, normalAttachment],
schemaVersion: 12,
}),
],
[
composeMessage(1, {
attachments: [
expectedRoundtrippedFields(longMessageAttachment),
expectedRoundtrippedFields(normalAttachment),
],
}),
]
);
});
it('migration creates long-message attachment if there is a long message.body (i.e. schemaVersion < 13)', async () => {
const body = 'a'.repeat(3000);
const bodyBytes = Bytes.fromString(body);
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body,
schemaVersion: 12,
}),
],
[
composeMessage(1, {
body: body.slice(0, 2048),
bodyAttachment: {
contentType: LONG_MESSAGE,
size: bodyBytes.byteLength,
plaintextHash: getPlaintextHashForInMemoryAttachment(bodyBytes),
},
}),
],
{
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(expected, 'bodyAttachment'),
omit(msgInDB, 'bodyAttachment')
);
assert.deepStrictEqual(
expected.bodyAttachment,
// all encryption info will be generated anew
omit(msgInDB.bodyAttachment, ['digest', 'key', 'downloadPath'])
);
assert.isUndefined(msgInDB.bodyAttachment?.digest);
assert.isTrue(isValidAttachmentKey(msgInDB.bodyAttachment?.key));
},
}
);
});
it('handles existing bodyAttachments', async () => {
const attachment = omit(
composeAttachment(1, {
contentType: LONG_MESSAGE,
size: 3000,
downloadPath: 'downloadPath',
}),
'thumbnail'
);
strictAssert(attachment.digest, 'must exist');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
bodyAttachment: attachment,
body: 'a'.repeat(3000),
}),
],
[
composeMessage(1, {
body: 'a'.repeat(2048),
bodyAttachment: expectedRoundtrippedFields(attachment),
}),
],
{
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(expected, 'bodyAttachment'),
omit(msgInDB, 'bodyAttachment')
);
assert.deepStrictEqual(
omit(expected.bodyAttachment, ['clientUuid', 'downloadPath']),
omit(msgInDB.bodyAttachment, ['clientUuid', 'downloadPath'])
);
assert.isNotEmpty(msgInDB.bodyAttachment?.downloadPath);
},
}
);
});
});
describe('normal attachments', () => {
it('BackupLevel.Free, roundtrips normal attachments', async () => {
const attachment1 = composeAttachment(1);
const attachment2 = composeAttachment(2);
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [attachment1, attachment2],
}),
],
[
composeMessage(1, {
attachments: [
expectedRoundtrippedFields(attachment1),
expectedRoundtrippedFields(attachment2),
],
}),
],
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Paid, roundtrips normal attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [attachment],
}),
],
[
composeMessage(1, {
attachments: [expectedRoundtrippedFields(attachment)],
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
it('roundtrips voice message attachments', async () => {
const attachment = composeAttachment(1);
attachment.contentType = AUDIO_MP3;
attachment.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
strictAssert(isVoiceMessage(attachment), 'it is a voice attachment');
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
attachments: [attachment],
}),
],
[
composeMessage(1, {
attachments: [expectedRoundtrippedFields(attachment)],
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
it('drops voice message flag when body is present', async () => {
const attachment = composeAttachment(1);
attachment.contentType = AUDIO_MP3;
attachment.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
strictAssert(isVoiceMessage(attachment), 'it is a voice attachment');
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: 'hello',
attachments: [attachment],
}),
],
[
composeMessage(1, {
body: 'hello',
attachments: [
{
...expectedRoundtrippedFields(attachment),
flags: undefined,
},
],
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('Preview attachments', () => {
it('BackupLevel.Free, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: 'https://signal.org',
preview: [
{ url: 'https://signal.org', date: 1, image: attachment },
],
}),
],
[
composeMessage(1, {
body: 'https://signal.org',
preview: [
{
url: 'https://signal.org',
date: 1,
image: expectedRoundtrippedFields(attachment),
},
],
}),
],
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Paid, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: 'https://signal.org',
preview: [
{
url: 'https://signal.org',
date: 1,
title: 'title',
description: 'description',
image: attachment,
},
],
}),
],
[
composeMessage(1, {
body: 'https://signal.org',
preview: [
{
url: 'https://signal.org',
date: 1,
title: 'title',
description: 'description',
image: expectedRoundtrippedFields(attachment),
},
],
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('contact attachments', () => {
it('BackupLevel.Free, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness(
[
composeMessage(1, {
contact: [{ avatar: { avatar: attachment, isProfile: false } }],
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
contact: [
{
avatar: {
avatar: expectedRoundtrippedFields(attachment),
isProfile: false,
},
},
],
}),
],
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Paid, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
await asymmetricRoundtripHarness(
[
composeMessage(1, {
contact: [{ avatar: { avatar: attachment, isProfile: false } }],
}),
],
[
composeMessage(1, {
contact: [
{
avatar: {
avatar: expectedRoundtrippedFields(attachment),
isProfile: false,
},
},
],
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('quotes', () => {
it('BackupLevel.Free, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
const authorAci = generateAci();
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,
id: Date.now(),
referencedMessageNotFound: false,
isGiftBadge: true,
attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }],
};
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: '123',
quote: quotedMessage,
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
body: '123',
quote: {
...quotedMessage,
attachments: [
{
thumbnail: expectedRoundtrippedFields(attachment),
contentType: VIDEO_MP4,
},
],
},
}),
],
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Paid, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci();
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,
id: Date.now(),
referencedMessageNotFound: false,
isGiftBadge: true,
attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }],
};
await asymmetricRoundtripHarness(
[
composeMessage(1, {
body: '123',
quote: quotedMessage,
}),
],
[
composeMessage(1, {
body: '123',
quote: {
...quotedMessage,
attachments: [
{
thumbnail: expectedRoundtrippedFields(attachment),
contentType: VIDEO_MP4,
},
],
},
}),
],
{ backupLevel: BackupLevel.Paid }
);
});
it('Copies data from message if it exists', async () => {
const existingAttachment = composeAttachment(1);
const existingMessageTimestamp = Date.now();
const existingMessage = composeMessage(existingMessageTimestamp, {
body: '123',
attachments: [existingAttachment],
});
const quoteAttachment = composeAttachment(2, { clientUuid: undefined });
delete quoteAttachment.thumbnail;
strictAssert(quoteAttachment.digest, 'digest exists');
strictAssert(existingAttachment.digest, 'digest exists');
const quotedMessage: QuotedMessageType = {
authorAci: existingMessage.sourceServiceId as AciString,
isViewOnce: false,
id: existingMessageTimestamp,
referencedMessageNotFound: false,
isGiftBadge: false,
attachments: [{ thumbnail: quoteAttachment, contentType: VIDEO_MP4 }],
};
const quoteMessage = composeMessage(existingMessageTimestamp + 1, {
body: 'quote',
quote: quotedMessage,
});
await asymmetricRoundtripHarness(
[existingMessage, quoteMessage],
[
{
...existingMessage,
attachments: [expectedRoundtrippedFields(existingAttachment)],
},
{
...quoteMessage,
quote: {
...quotedMessage,
referencedMessageNotFound: false,
attachments: [
{
// The thumbnail will not have been copied over yet since it has not yet
// been downloaded
thumbnail: expectedRoundtrippedFields(quoteAttachment),
contentType: VIDEO_MP4,
},
],
},
},
],
{ backupLevel: BackupLevel.Paid }
);
});
it('handles quotes which have been copied over from the original (and lack all encryption info)', async () => {
const originalMessage = composeMessage(1, {
body: 'original',
});
const quotedMessage: QuotedMessageType = {
authorAci: originalMessage.sourceServiceId as AciString,
isViewOnce: false,
id: originalMessage.timestamp,
referencedMessageNotFound: false,
isGiftBadge: false,
attachments: [
{
thumbnail: {
contentType: IMAGE_PNG,
size: 100,
path: 'path/to/thumbnail',
localKey: Bytes.toBase64(generateAttachmentKeys()),
plaintextHash: Bytes.toHex(getRandomBytes(32)),
},
contentType: VIDEO_MP4,
},
],
};
const quoteMessage = composeMessage(originalMessage.timestamp + 1, {
body: 'quote',
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.Paid,
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, plaintextHash } = thumbnail;
strictAssert(thumbnail, 'thumbnail exists');
strictAssert(key, 'thumbnail key was created');
strictAssert(plaintextHash, 'quote plaintextHash was roundtripped');
strictAssert(
hasRequiredInformationForBackup(thumbnail),
'has key and plaintextHash'
);
assert.deepStrictEqual(thumbnail, {
contentType: IMAGE_PNG,
size: 100,
key: thumbnail.key,
plaintextHash: thumbnail.plaintextHash,
});
},
}
);
});
});
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)', () => {
// TODO: DESKTOP-8896
it.skip('BackupLevel.Paid, 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.Paid,
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,
});
},
}
);
});
it('BackupLevel.Free, 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.Free,
}
);
});
});
describe('when this device sent sticker (i.e. encryption info exists on message)', () => {
it('roundtrips sticker', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
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: expectedRoundtrippedFields(attachment),
},
}),
],
{
backupLevel: BackupLevel.Paid,
}
);
});
});
});
});