From cf381cd46c280e15d31a51b08d052367a97ee7d2 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Wed, 6 Mar 2024 13:15:10 -0500 Subject: [PATCH] Generate mediaName for backed-up attachments --- ts/AttachmentCrypto.ts | 2 +- .../backup/backup_attachments_test.ts | 102 ++++++++++++++++++ ts/textsecure/downloadAttachment.ts | 2 +- ts/util/attachments/getMediaNameForBackup.ts | 67 ++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 ts/test-electron/backup/backup_attachments_test.ts create mode 100644 ts/util/attachments/getMediaNameForBackup.ts diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index 5611b887f..3bc178714 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -525,7 +525,7 @@ export async function addPlaintextHashToAttachment( }; } -async function getPlaintextHashForAttachmentOnDisk( +export async function getPlaintextHashForAttachmentOnDisk( absolutePath: string ): Promise { let readFd; diff --git a/ts/test-electron/backup/backup_attachments_test.ts b/ts/test-electron/backup/backup_attachments_test.ts new file mode 100644 index 000000000..a6b1ff82d --- /dev/null +++ b/ts/test-electron/backup/backup_attachments_test.ts @@ -0,0 +1,102 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { getMediaNameForBackup } from '../../util/attachments/getMediaNameForBackup'; +import { IMAGE_PNG } from '../../types/MIME'; +import { sha256 } from '../../Crypto'; +import { DAY } from '../../util/durations'; + +describe('getMediaNameForBackup', () => { + const TEST_HASH = sha256(Buffer.from('testattachmentdata')); + const TEST_HASH_BASE_64 = + // calculated as Buffer.from(TEST_HASH).toString('base64') + 'ds5/U14lB2ziO90B7MldFTJUQdyw4qQ9y6Gnt9fmHL0='; + + afterEach(function (this: Mocha.Context) { + sinon.restore(); + }); + + it("should return base64 encoded plaintextHash if it's already been calculated", async () => { + assert.strictEqual( + await getMediaNameForBackup( + { + contentType: IMAGE_PNG, + size: 100, + plaintextHash: Buffer.from(TEST_HASH).toString('hex'), + }, + 'senderAci', + Date.now() + ), + TEST_HASH_BASE_64 + ); + }); + + it('should calculate hash from file on disk if plaintextHash has not yet been calculated', async () => { + const stubbedGetHashFromDisk = sinon + .stub() + .callsFake(async (_path: string) => + Buffer.from(TEST_HASH).toString('hex') + ); + + const mediaName = await getMediaNameForBackup( + { + contentType: IMAGE_PNG, + size: 100, + path: 'path/to/file', + }, + 'senderAci', + Date.now(), + { getPlaintextHashForAttachmentOnDisk: stubbedGetHashFromDisk } + ); + + assert.strictEqual(stubbedGetHashFromDisk.callCount, 1); + assert.strictEqual(mediaName, TEST_HASH_BASE_64); + }); + + it('should return temporary identifier if attachment is undownloaded but in attachment tier', async () => { + const mediaName = await getMediaNameForBackup( + { + contentType: IMAGE_PNG, + size: 100, + cdnKey: 'cdnKey', + }, + 'senderAci', + Date.now() + ); + + assert.strictEqual(mediaName, 'senderAci_cdnKey'); + }); + + it('should return temporary identifier if undownloaded attachment has temporary error', async () => { + const mediaName = await getMediaNameForBackup( + { + contentType: IMAGE_PNG, + size: 100, + cdnKey: 'cdnKey', + error: true, + key: 'attachmentkey', + }, + 'senderAci', + Date.now() + ); + + assert.strictEqual(mediaName, 'senderAci_cdnKey'); + }); + + it('should return undefined if attachment is too old to be in attachment tier', async () => { + const mediaName = await getMediaNameForBackup( + { + contentType: IMAGE_PNG, + size: 100, + cdnKey: 'cdnKey', + }, + 'senderAci', + Date.now() - 31 * DAY + ); + + assert.strictEqual(mediaName, undefined); + }); +}); diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index 339eab2de..946d2053b 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -28,7 +28,7 @@ import type { ProcessedAttachment } from './Types.d'; import type { WebAPIType } from './WebAPI'; import { createName, getRelativePath } from '../windows/attachments'; -function getCdn(attachment: ProcessedAttachment) { +export function getCdn(attachment: ProcessedAttachment): string { const { cdnId, cdnKey } = attachment; const cdn = cdnId || cdnKey; strictAssert(cdn, 'Attachment was missing cdnId or cdnKey'); diff --git a/ts/util/attachments/getMediaNameForBackup.ts b/ts/util/attachments/getMediaNameForBackup.ts new file mode 100644 index 000000000..7cb3f1c5b --- /dev/null +++ b/ts/util/attachments/getMediaNameForBackup.ts @@ -0,0 +1,67 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { getPlaintextHashForAttachmentOnDisk } from '../../AttachmentCrypto'; +import type { AttachmentType } from '../../types/Attachment'; +import { DAY } from '../durations'; +import * as log from '../../logging/log'; +import { isOlderThan } from '../timestamp'; +import { getCdn } from '../../textsecure/downloadAttachment'; +import * as Bytes from '../../Bytes'; + +const TIME_IN_ATTACHMENT_TIER = 30 * DAY; + +// We store the plaintext hash as a hex string, but the mediaName should be +// the base64 encoded version. +function convertHexStringToBase64(hexString: string): string { + return Bytes.toBase64(Bytes.fromHex(hexString)); +} + +type GetMediaNameDependenciesType = { + getPlaintextHashForAttachmentOnDisk: ( + path: string + ) => Promise; +}; + +export async function getMediaNameForBackup( + attachment: AttachmentType, + senderAci: string, + messageTimestamp: number, + // allow optional dependency injection for testing + dependencies: GetMediaNameDependenciesType = { + getPlaintextHashForAttachmentOnDisk, + } +): Promise { + if (attachment.plaintextHash) { + return convertHexStringToBase64(attachment.plaintextHash); + } + + if (attachment.path) { + const hashFromFileOnDisk = + await dependencies.getPlaintextHashForAttachmentOnDisk( + window.Signal.Migrations.getAbsoluteAttachmentPath(attachment.path) + ); + if (!hashFromFileOnDisk) { + log.error( + 'getMediaNameForBackup: no hash from attachment on disk (maybe it is empty?)' + ); + return; + } + return convertHexStringToBase64(hashFromFileOnDisk); + } + + const cdnKey = getCdn(attachment); + if (!cdnKey) { + log.error('getMediaNameForBackup: attachment has no cdnKey'); + return; + } + + if (isOlderThan(messageTimestamp, TIME_IN_ATTACHMENT_TIER)) { + log.error( + "getMediaNameForBackup: attachment is not downloaded but is too old; it's no longer in attachment tier." + ); + return; + } + + return `${senderAci}_${cdnKey}`; +}