Discard invalid incrementalMacs

This commit is contained in:
trevor-signal
2025-09-08 16:19:17 -04:00
committed by GitHub
parent ebdf651dca
commit b92c0e95e8
14 changed files with 304 additions and 88 deletions

View File

@@ -134,7 +134,7 @@ async function safeDecryptToSink(
}); });
file.on('error', (error: Error) => { file.on('error', (error: Error) => {
log.warn( log.warn(
'safeDecryptToSync/incremental: growing-file emitted an error:', 'safeDecryptToSink/incremental: growing-file emitted an error:',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
}); });

View File

@@ -130,7 +130,7 @@
"@react-aria/utils": "3.25.3", "@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@react-types/shared": "3.27.0", "@react-types/shared": "3.27.0",
"@signalapp/libsignal-client": "0.79.1", "@signalapp/libsignal-client": "0.80.0",
"@signalapp/minimask": "1.0.1", "@signalapp/minimask": "1.0.1",
"@signalapp/quill-cjs": "2.1.2", "@signalapp/quill-cjs": "2.1.2",
"@signalapp/ringrtc": "2.57.1", "@signalapp/ringrtc": "2.57.1",

10
pnpm-lock.yaml generated
View File

@@ -126,8 +126,8 @@ importers:
specifier: 3.27.0 specifier: 3.27.0
version: 3.27.0(react@18.3.1) version: 3.27.0(react@18.3.1)
'@signalapp/libsignal-client': '@signalapp/libsignal-client':
specifier: 0.79.1 specifier: 0.80.0
version: 0.79.1 version: 0.80.0
'@signalapp/minimask': '@signalapp/minimask':
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
@@ -3296,8 +3296,8 @@ packages:
'@signalapp/libsignal-client@0.76.7': '@signalapp/libsignal-client@0.76.7':
resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==} resolution: {integrity: sha512-iGWTlFkko7IKlm96Iy91Wz5sIN089nj02ifOk6BWtLzeVi0kFaNj+jK26Sl1JRXy/VfXevcYtiOivOg43BPqpg==}
'@signalapp/libsignal-client@0.79.1': '@signalapp/libsignal-client@0.80.0':
resolution: {integrity: sha512-mL+SCJEbDqLYpd5JtA53JgjuYinrWF7jSonA2bD3HJMdHyAJ1jP+ThPD3Vh71yR+EhTCflyr5Tm43sQ0KoLtlg==} resolution: {integrity: sha512-cOFORDUUSdQFjQ8RDA8niC1UIoFRi7+Dd8ZNizGcJ2Q16D9RhKU9yZ0/VIef+mWu/iJcPovoC3GCvw0nE881vw==}
'@signalapp/minimask@1.0.1': '@signalapp/minimask@1.0.1':
resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==} resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==}
@@ -13943,7 +13943,7 @@ snapshots:
type-fest: 4.26.1 type-fest: 4.26.1
uuid: 11.0.2 uuid: 11.0.2
'@signalapp/libsignal-client@0.79.1': '@signalapp/libsignal-client@0.80.0':
dependencies: dependencies:
node-gyp-build: 4.8.4 node-gyp-build: 4.8.4
type-fest: 4.26.1 type-fest: 4.26.1

View File

@@ -582,9 +582,10 @@ export async function decryptAndReencryptLocally(
}; };
} catch (error) { } catch (error) {
log.error( log.error(
`${logId}: Failed to decrypt attachment`, `${logId}: Failed to decrypt and reencrypt attachment`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
await safeUnlink(absoluteTargetPath); await safeUnlink(absoluteTargetPath);
throw error; throw error;
} finally { } finally {

View File

@@ -13,7 +13,10 @@ import {
AttachmentDownloadUrgency, AttachmentDownloadUrgency,
coreAttachmentDownloadJobSchema, coreAttachmentDownloadJobSchema,
} from '../types/AttachmentDownload'; } from '../types/AttachmentDownload';
import { downloadAttachment as downloadAttachmentUtil } from '../util/downloadAttachment'; import {
downloadAttachment as downloadAttachmentUtil,
isIncrementalMacVerificationError,
} from '../util/downloadAttachment';
import { DataReader, DataWriter } from '../sql/Client'; import { DataReader, DataWriter } from '../sql/Client';
import { getValue } from '../RemoteConfig'; import { getValue } from '../RemoteConfig';
@@ -27,6 +30,7 @@ import {
canAttachmentHaveThumbnail, canAttachmentHaveThumbnail,
shouldAttachmentEndUpInRemoteBackup, shouldAttachmentEndUpInRemoteBackup,
getUndownloadedAttachmentSignature, getUndownloadedAttachmentSignature,
isIncremental,
} from '../types/Attachment'; } from '../types/Attachment';
import { type ReadonlyMessageAttributesType } from '../model-types.d'; import { type ReadonlyMessageAttributesType } from '../model-types.d';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
@@ -44,7 +48,7 @@ import {
type JobManagerJobResultType, type JobManagerJobResultType,
type JobManagerJobType, type JobManagerJobType,
} from './JobManager'; } from './JobManager';
import { IMAGE_JPEG } from '../types/MIME'; import { IMAGE_WEBP } from '../types/MIME';
import { AttachmentDownloadSource } from '../sql/Interface'; import { AttachmentDownloadSource } from '../sql/Interface';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { import {
@@ -506,8 +510,8 @@ async function runDownloadAttachmentJob({
if (result.downloadedVariant === AttachmentVariant.ThumbnailFromBackup) { if (result.downloadedVariant === AttachmentVariant.ThumbnailFromBackup) {
return { return {
status: 'finished', status: 'retry',
newJob: { ...job, attachment: result.attachmentWithThumbnail }, updatedJob: { ...job, attachment: result.attachmentWithThumbnail },
}; };
} }
@@ -543,6 +547,38 @@ async function runDownloadAttachmentJob({
return { status: 'finished' }; return { status: 'finished' };
} }
if (isIncrementalMacVerificationError(error)) {
log.warn(
'Attachment decryption failed with incrementalMac verification error; dropping incrementalMac'
);
// If incrementalMac fails verification, we will delete it and retry (without
// streaming support)
strictAssert(isIncremental(job.attachment), 'must have incrementalMac');
const attachmentWithoutIncrementalMac: AttachmentType = {
...job.attachment,
pending: false,
incrementalMac: undefined,
chunkSize: undefined,
};
// Double-check it no longer supports incremental playback just to make sure we
// avoid any loops
strictAssert(
!isIncremental(attachmentWithoutIncrementalMac),
'no longer has incrementalMac'
);
await addAttachmentToMessage(
message.id,
attachmentWithoutIncrementalMac,
logId,
{ type: job.attachmentType }
);
return {
status: 'retry',
updatedJob: { ...job, attachment: attachmentWithoutIncrementalMac },
};
}
if (error instanceof AttachmentPermanentlyUndownloadableError) { if (error instanceof AttachmentPermanentlyUndownloadableError) {
const canBackfill = const canBackfill =
job.isManualDownload && job.isManualDownload &&
@@ -672,27 +708,28 @@ export async function runDownloadAttachmentJobInner({
canAttachmentHaveThumbnail(attachment) && canAttachmentHaveThumbnail(attachment) &&
!wasAttachmentImportedFromLocalBackup; !wasAttachmentImportedFromLocalBackup;
const preferBackupThumbnail = const attemptBackupThumbnailFirst =
isForCurrentlyVisibleMessage && mightHaveBackupThumbnailToDownload; isForCurrentlyVisibleMessage && mightHaveBackupThumbnailToDownload;
if (preferBackupThumbnail) { let attachmentWithBackupThumbnail: AttachmentType | undefined;
logId += '.preferringBackupThumbnail';
} if (attemptBackupThumbnailFirst) {
logId += '.preferringBackupThumbnail';
if (preferBackupThumbnail) {
try { try {
const attachmentWithThumbnail = await downloadBackupThumbnail({ attachmentWithBackupThumbnail = await downloadBackupThumbnail({
attachment, attachment,
abortSignal, abortSignal,
dependencies, dependencies,
}); });
await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, { await addAttachmentToMessage(
type: attachmentType, messageId,
}); attachmentWithBackupThumbnail,
return { logId,
downloadedVariant: AttachmentVariant.ThumbnailFromBackup, {
attachmentWithThumbnail, type: attachmentType,
}; }
);
} catch (e) { } catch (e) {
log.warn( log.warn(
`${logId}: error when trying to download thumbnail`, `${logId}: error when trying to download thumbnail`,
@@ -801,32 +838,25 @@ export async function runDownloadAttachmentJobInner({
); );
return { downloadedVariant: AttachmentVariant.Default }; return { downloadedVariant: AttachmentVariant.Default };
} catch (error) { } catch (error) {
if (mightHaveBackupThumbnailToDownload && !preferBackupThumbnail) { if (mightHaveBackupThumbnailToDownload && !attemptBackupThumbnailFirst) {
log.error( log.error(
`${logId}: failed to download fullsize attachment, falling back to backup thumbnail`, `${logId}: failed to download fullsize attachment, falling back to backup thumbnail`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
try { try {
const attachmentWithThumbnail = omit( attachmentWithBackupThumbnail = await downloadBackupThumbnail({
await downloadBackupThumbnail({ attachment,
attachment, abortSignal,
abortSignal, dependencies,
dependencies, });
}),
'pending'
);
await addAttachmentToMessage( await addAttachmentToMessage(
messageId, messageId,
attachmentWithThumbnail, { ...attachment, pending: false },
logId, logId,
{ {
type: attachmentType, type: attachmentType,
} }
); );
return {
downloadedVariant: AttachmentVariant.ThumbnailFromBackup,
attachmentWithThumbnail,
};
} catch (thumbnailError) { } catch (thumbnailError) {
log.error( log.error(
`${logId}: fallback attempt to download thumbnail failed`, `${logId}: fallback attempt to download thumbnail failed`,
@@ -835,6 +865,13 @@ export async function runDownloadAttachmentJobInner({
} }
} }
if (attachmentWithBackupThumbnail) {
return {
downloadedVariant: AttachmentVariant.ThumbnailFromBackup,
attachmentWithThumbnail: attachmentWithBackupThumbnail,
};
}
let showToast = false; let showToast = false;
// Show toast if manual download failed // Show toast if manual download failed
@@ -889,7 +926,7 @@ async function downloadBackupThumbnail({
const attachmentWithThumbnail = { const attachmentWithThumbnail = {
...attachment, ...attachment,
thumbnailFromBackup: { thumbnailFromBackup: {
contentType: IMAGE_JPEG, contentType: IMAGE_WEBP,
...downloadedThumbnail, ...downloadedThumbnail,
size: calculatedSize, size: calculatedSize,
}, },

View File

@@ -66,10 +66,8 @@ export type JobManagerParamsType<
const DEFAULT_TICK_INTERVAL = MINUTE; const DEFAULT_TICK_INTERVAL = MINUTE;
export type JobManagerJobResultType<CoreJobType> = export type JobManagerJobResultType<CoreJobType> =
| { | { status: 'retry'; updatedJob?: CoreJobType & JobManagerJobType }
status: 'retry'; | { status: 'finished' }
}
| { status: 'finished'; newJob?: CoreJobType }
| { status: 'rate-limited'; pauseDurationMs: number }; | { status: 'rate-limited'; pauseDurationMs: number };
export type ActiveJobData<CoreJobType> = { export type ActiveJobData<CoreJobType> = {
@@ -287,6 +285,7 @@ export abstract class JobManager<CoreJobType> {
return; return;
} }
const isFirstAttempt = job.attempts === 0;
const isLastAttempt = const isLastAttempt =
job.attempts + 1 >= job.attempts + 1 >=
(this.params.getRetryConfig(job).maxAttempts ?? Infinity); (this.params.getRetryConfig(job).maxAttempts ?? Infinity);
@@ -303,7 +302,9 @@ export abstract class JobManager<CoreJobType> {
this.#handleJobStartPromises(job); this.#handleJobStartPromises(job);
jobRunResult = await runJobPromise; jobRunResult = await runJobPromise;
const { status } = jobRunResult; const { status } = jobRunResult;
log.info(`${logId}: job completed with status: ${status}`); log.info(
`${logId}: job completed with status: ${status}${status === 'retry' && jobRunResult.updatedJob ? ' with updated job' : ''}`
);
switch (status) { switch (status) {
case 'finished': case 'finished':
@@ -313,7 +314,13 @@ export abstract class JobManager<CoreJobType> {
if (isLastAttempt) { if (isLastAttempt) {
throw new Error('Cannot retry on last attempt'); throw new Error('Cannot retry on last attempt');
} }
await this.#retryJobLater(job); // If we get an updated job, retry it without delay only if it's the first
// attempt (to avoid loops)
if (isFirstAttempt && jobRunResult.updatedJob) {
await this.#retryJobWithoutDelay(jobRunResult.updatedJob);
} else {
await this.#retryJobLater(jobRunResult.updatedJob ?? job);
}
return; return;
case 'rate-limited': case 'rate-limited':
log.info( log.info(
@@ -334,18 +341,21 @@ export abstract class JobManager<CoreJobType> {
} }
} finally { } finally {
this.#removeRunningJob(job); this.#removeRunningJob(job);
if (jobRunResult?.status === 'finished') {
if (jobRunResult.newJob) {
log.info(
`${logId}: adding new job as a result of this one completing`
);
await this.addJob(jobRunResult.newJob);
}
}
drop(this.maybeStartJobs()); drop(this.maybeStartJobs());
} }
} }
async #retryJobWithoutDelay(job: CoreJobType & JobManagerJobType) {
const now = Date.now();
await this.params.saveJob({
...job,
active: false,
attempts: job.attempts + 1,
retryAfter: now,
lastAttemptTimestamp: now,
});
}
async #retryJobLater(job: CoreJobType & JobManagerJobType) { async #retryJobLater(job: CoreJobType & JobManagerJobType) {
const now = Date.now(); const now = Date.now();
await this.params.saveJob({ await this.params.saveJob({

View File

@@ -856,7 +856,7 @@ describe('Crypto', () => {
plaintextHash, plaintextHash,
modifyIncrementalMac: true, modifyIncrementalMac: true,
}), }),
/Corrupted/ /^Corrupted input data/
); );
} finally { } finally {
unlinkSync(sourcePath); unlinkSync(sourcePath);

View File

@@ -527,6 +527,44 @@ describe('AttachmentDownloadManager/JobManager', () => {
await jobAttempts[1].completed; await jobAttempts[1].completed;
}); });
it('retries job with updated job if provided', async () => {
strictAssert(downloadManager, 'must exist');
const job = (
await addJobs(1, {
source: AttachmentDownloadSource.BACKUP_IMPORT,
})
)[0];
const jobAttempts = getPromisesForAttempts(job, 3);
runJob.callsFake(async args => {
return new Promise(resolve => {
Promise.resolve().then(() => {
resolve({
status: 'retry',
updatedJob: {
...args.job,
attachment: { ...job.attachment, caption: 'retried' },
},
});
});
});
});
await downloadManager?.start();
await jobAttempts[0].completed;
assertRunJobCalledWith([job]);
await jobAttempts[1].completed;
assert.deepStrictEqual(
runJob.getCall(0).args[0].job.attachment,
job.attachment
);
assert.deepStrictEqual(runJob.getCall(1).args[0].job.attachment, {
...job.attachment,
caption: 'retried',
});
});
describe('will drop jobs from non-media backup imports that are old', () => { describe('will drop jobs from non-media backup imports that are old', () => {
it('will not queue attachments older than 90 days (2 * message queue time)', async () => { it('will not queue attachments older than 90 days (2 * message queue time)', async () => {
hasMediaBackups.returns(false); hasMediaBackups.returns(false);
@@ -660,12 +698,20 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
AttachmentVariant.Default AttachmentVariant.Default
); );
}); });
it('will download thumbnail if attachment is from backup', async () => {
it('will download thumbnail first if attachment is from backup', async () => {
const job = composeJob({ const job = composeJob({
messageId: '1', messageId: '1',
receivedAt: 1, receivedAt: 1,
}); });
downloadAttachment = sandbox.stub().callsFake(({ options }) => {
if (options.variant === AttachmentVariant.ThumbnailFromBackup) {
return Promise.resolve(downloadedAttachment);
}
throw new Error('error while downloading');
});
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: true, isForCurrentlyVisibleMessage: true,
@@ -692,16 +738,23 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
result.attachmentWithThumbnail.thumbnailFromBackup?.path, result.attachmentWithThumbnail.thumbnailFromBackup?.path,
'/path/to/file' '/path/to/file'
); );
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 2);
const downloadCallArgs = downloadAttachment.getCall(0).args[0]; const firstDownloadCallArgs = downloadAttachment.getCall(0).args[0];
assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); assert.deepStrictEqual(firstDownloadCallArgs.attachment, job.attachment);
assert.deepStrictEqual( assert.deepStrictEqual(
downloadCallArgs.options.variant, firstDownloadCallArgs.options.variant,
AttachmentVariant.ThumbnailFromBackup AttachmentVariant.ThumbnailFromBackup
); );
const secondDownloadCallArgs = downloadAttachment.getCall(1).args[0];
assert.deepStrictEqual(
secondDownloadCallArgs.options.variant,
AttachmentVariant.Default
);
}); });
it('will download full size if thumbnail already backed up', async () => {
it('will download full size if backup thumbnail already downloaded', async () => {
const job = composeJob({ const job = composeJob({
messageId: '1', messageId: '1',
receivedAt: 1, receivedAt: 1,
@@ -739,7 +792,10 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
}); });
it('will attempt to download full size if thumbnail fails', async () => { it('will attempt to download full size if thumbnail fails', async () => {
downloadAttachment = sandbox.stub().callsFake(() => { downloadAttachment = sandbox.stub().callsFake(({ options }) => {
if (options.variant === AttachmentVariant.Default) {
return Promise.resolve(downloadedAttachment);
}
throw new Error('error while downloading'); throw new Error('error while downloading');
}); });
@@ -748,22 +804,20 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
receivedAt: 1, receivedAt: 1,
}); });
await assert.isRejected( const result = await runDownloadAttachmentJobInner({
runDownloadAttachmentJobInner({ job,
job, isForCurrentlyVisibleMessage: true,
isForCurrentlyVisibleMessage: true, hasMediaBackups: true,
hasMediaBackups: true, abortSignal: abortController.signal,
abortSignal: abortController.signal, maxAttachmentSizeInKib: 100 * MEBIBYTE,
maxAttachmentSizeInKib: 100 * MEBIBYTE, maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
maxTextAttachmentSizeInKib: 2 * MEBIBYTE, dependencies: {
dependencies: { deleteDownloadData,
deleteDownloadData, downloadAttachment,
downloadAttachment, processNewAttachment,
processNewAttachment, },
}, });
}) assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
);
assert.strictEqual(downloadAttachment.callCount, 2); assert.strictEqual(downloadAttachment.callCount, 2);
const downloadCallArgs0 = downloadAttachment.getCall(0).args[0]; const downloadCallArgs0 = downloadAttachment.getCall(0).args[0];

View File

@@ -219,7 +219,7 @@ describe('backups', function (this: Mocha.Suite) {
const catTimestamp = bootstrap.getTimestamp(); const catTimestamp = bootstrap.getTimestamp();
const plaintextCat = await readFile(CAT_PATH); const plaintextCat = await readFile(CAT_PATH);
const ciphertextCat = await bootstrap.storeAttachmentOnCDN( const ciphertextCat = await bootstrap.encryptAndStoreAttachmentOnCDN(
plaintextCat, plaintextCat,
IMAGE_JPEG IMAGE_JPEG
); );

View File

@@ -674,7 +674,7 @@ export class Bootstrap {
return join(this.#storagePath, 'attachments.noindex', relativePath); return join(this.#storagePath, 'attachments.noindex', relativePath);
} }
public async storeAttachmentOnCDN( public async encryptAndStoreAttachmentOnCDN(
data: Buffer, data: Buffer,
contentType: MIMEType contentType: MIMEType
): Promise<Proto.IAttachmentPointer> { ): Promise<Proto.IAttachmentPointer> {
@@ -684,13 +684,13 @@ export class Bootstrap {
const passthrough = new PassThrough(); const passthrough = new PassThrough();
const [{ digest }] = await Promise.all([ const [{ digest, chunkSize, incrementalMac }] = await Promise.all([
encryptAttachmentV2({ encryptAttachmentV2({
keys, keys,
plaintext: { plaintext: {
data, data,
}, },
needIncrementalMac: false, needIncrementalMac: true,
sink: passthrough, sink: passthrough,
}), }),
this.server.storeAttachmentOnCdn(cdnNumber, cdnKey, passthrough), this.server.storeAttachmentOnCdn(cdnNumber, cdnKey, passthrough),
@@ -703,6 +703,8 @@ export class Bootstrap {
cdnNumber, cdnNumber,
key: keys, key: keys,
digest, digest,
chunkSize,
incrementalMac,
}; };
} }

View File

@@ -6,6 +6,8 @@ import { assert } from 'chai';
import { expect } from 'playwright/test'; import { expect } from 'playwright/test';
import { type PrimaryDevice, StorageState } from '@signalapp/mock-server'; import { type PrimaryDevice, StorageState } from '@signalapp/mock-server';
import * as path from 'path'; import * as path from 'path';
import { readFile } from 'fs/promises';
import type { App } from '../playwright'; import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap'; import { Bootstrap } from '../bootstrap';
import { import {
@@ -16,6 +18,8 @@ import {
} from '../helpers'; } from '../helpers';
import * as durations from '../../util/durations'; import * as durations from '../../util/durations';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { VIDEO_MP4 } from '../../types/MIME';
import { toBase64 } from '../../Bytes';
export const debug = createDebug('mock:test:attachments'); export const debug = createDebug('mock:test:attachments');
@@ -27,6 +31,14 @@ const CAT_PATH = path.join(
'fixtures', 'fixtures',
'cat-screenshot.png' 'cat-screenshot.png'
); );
const VIDEO_PATH = path.join(
__dirname,
'..',
'..',
'..',
'fixtures',
'ghost-kitty.mp4'
);
describe('attachments', function (this: Mocha.Suite) { describe('attachments', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE); this.timeout(durations.MINUTE);
@@ -121,4 +133,83 @@ describe('attachments', function (this: Mocha.Suite) {
assert.strictEqual(incomingAttachment?.key, sentAttachment?.key); assert.strictEqual(incomingAttachment?.key, sentAttachment?.key);
assert.strictEqual(incomingAttachment?.digest, sentAttachment?.digest); assert.strictEqual(incomingAttachment?.digest, sentAttachment?.digest);
}); });
it('can download videos with incrementalMac and is resilient to bad incrementalMacs', async () => {
const { desktop } = bootstrap;
const page = await app.getWindow();
await page.getByTestId(pinned.device.aci).click();
const plaintextVideo = await readFile(VIDEO_PATH);
const videoPointer1 = await bootstrap.encryptAndStoreAttachmentOnCDN(
plaintextVideo,
VIDEO_MP4
);
const videoPointer2 = await bootstrap.encryptAndStoreAttachmentOnCDN(
plaintextVideo,
VIDEO_MP4
);
const incrementalTimestamp = Date.now();
const badIncrementalTimestamp = incrementalTimestamp + 1;
await sendTextMessage({
from: pinned,
to: desktop,
desktop,
text: 'video with good incrementalMac',
attachments: [videoPointer1],
timestamp: incrementalTimestamp,
});
await sendTextMessage({
from: pinned,
to: desktop,
desktop,
text: 'video with bad incrementalMac',
attachments: [
{ ...videoPointer2, chunkSize: (videoPointer2.chunkSize ?? 42) + 1 },
],
timestamp: badIncrementalTimestamp,
});
await expect(
getMessageInTimelineByTimestamp(page, incrementalTimestamp).locator(
'img.module-image__image'
)
).toBeVisible();
await expect(
getMessageInTimelineByTimestamp(page, badIncrementalTimestamp).locator(
'img.module-image__image'
)
).toBeVisible();
// goodIncrementalMac preserved
{
const messageInDB = (
await app.getMessagesBySentAt(incrementalTimestamp)
)[0];
strictAssert(messageInDB, 'message exists in DB');
const attachmentInDB = messageInDB.attachments?.[0];
strictAssert(videoPointer1.incrementalMac, 'must exist');
strictAssert(videoPointer1.chunkSize, 'must exist');
assert.strictEqual(
attachmentInDB?.incrementalMac,
toBase64(videoPointer1.incrementalMac)
);
assert.strictEqual(attachmentInDB?.chunkSize, videoPointer1.chunkSize);
}
// badIncrementalMac removed
{
const messageInDB = (
await app.getMessagesBySentAt(badIncrementalTimestamp)
)[0];
strictAssert(messageInDB, 'message exists in DB');
const attachmentInDB = messageInDB.attachments?.[0];
strictAssert(videoPointer2.incrementalMac, 'must exist');
strictAssert(videoPointer2.chunkSize, 'must exist');
assert.strictEqual(attachmentInDB?.incrementalMac, undefined);
assert.strictEqual(attachmentInDB?.chunkSize, undefined);
}
});
}); });

View File

@@ -51,19 +51,19 @@ describe('attachment backfill', function (this: Mocha.Suite) {
const { unknownContacts } = bootstrap; const { unknownContacts } = bootstrap;
[unknownContact] = unknownContacts; [unknownContact] = unknownContacts;
textAttachment = await bootstrap.storeAttachmentOnCDN( textAttachment = await bootstrap.encryptAndStoreAttachmentOnCDN(
Buffer.from('look at this pic, it is gorgeous!'), Buffer.from('look at this pic, it is gorgeous!'),
LONG_MESSAGE LONG_MESSAGE
); );
const plaintextCat = await readFile(CAT_PATH); const plaintextCat = await readFile(CAT_PATH);
catAttachment = await bootstrap.storeAttachmentOnCDN( catAttachment = await bootstrap.encryptAndStoreAttachmentOnCDN(
plaintextCat, plaintextCat,
IMAGE_JPEG IMAGE_JPEG
); );
const plaintextSnow = await readFile(SNOW_PATH); const plaintextSnow = await readFile(SNOW_PATH);
snowAttachment = await bootstrap.storeAttachmentOnCDN( snowAttachment = await bootstrap.encryptAndStoreAttachmentOnCDN(
plaintextSnow, plaintextSnow,
IMAGE_JPEG IMAGE_JPEG
); );

View File

@@ -164,7 +164,10 @@ export async function downloadAttachment(
} }
// Start over if we go over the size // Start over if we go over the size
if (downloadOffset >= size && absoluteDownloadPath) { if (
downloadOffset >= getAttachmentCiphertextLength(size) &&
absoluteDownloadPath
) {
log.warn('went over, retrying'); log.warn('went over, retrying');
await safeUnlink(absoluteDownloadPath); await safeUnlink(absoluteDownloadPath);
downloadOffset = 0; downloadOffset = 0;
@@ -310,7 +313,7 @@ export async function downloadAttachment(
// backup thumbnails don't get trimmed, so we just calculate the size as the // backup thumbnails don't get trimmed, so we just calculate the size as the
// ciphertextSize, less IV and MAC // ciphertextSize, less IV and MAC
const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH; const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH;
return decryptAndReencryptLocally({ return await decryptAndReencryptLocally({
type: 'backupThumbnail', type: 'backupThumbnail',
ciphertextPath: cipherTextAbsolutePath, ciphertextPath: cipherTextAbsolutePath,
idForLogging: logId, idForLogging: logId,

View File

@@ -1,6 +1,6 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ErrorCode, LibSignalErrorBase } from '@signalapp/libsignal-client';
import { import {
type AttachmentType, type AttachmentType,
AttachmentVariant, AttachmentVariant,
@@ -68,6 +68,9 @@ export async function downloadAttachment({
onSizeUpdate(attachment.size); onSizeUpdate(attachment.size);
return result; return result;
} catch (error) { } catch (error) {
if (isIncrementalMacVerificationError(error)) {
throw error;
}
// We also just log this error instead of throwing, since we want to still try to // We also just log this error instead of throwing, since we want to still try to
// find it on the backup then transit tiers. // find it on the backup then transit tiers.
log.error( log.error(
@@ -90,6 +93,10 @@ export async function downloadAttachment({
} }
); );
} catch (error) { } catch (error) {
if (isIncrementalMacVerificationError(error)) {
throw error;
}
const shouldFallbackToTransitTier = const shouldFallbackToTransitTier =
variant !== AttachmentVariant.ThumbnailFromBackup; variant !== AttachmentVariant.ThumbnailFromBackup;
@@ -128,6 +135,10 @@ export async function downloadAttachment({
} }
); );
} catch (error) { } catch (error) {
if (isIncrementalMacVerificationError(error)) {
throw error;
}
if (mightBeOnBackupTierInTheFuture) { if (mightBeOnBackupTierInTheFuture) {
// We don't want to throw the AttachmentPermanentlyUndownloadableError because we // We don't want to throw the AttachmentPermanentlyUndownloadableError because we
// may just need to wait for this attachment to end up on the backup tier // may just need to wait for this attachment to end up on the backup tier
@@ -150,3 +161,10 @@ export async function downloadAttachment({
} }
} }
} }
export function isIncrementalMacVerificationError(error: unknown): boolean {
return (
error instanceof LibSignalErrorBase &&
error.code === ErrorCode.IncrementalMacVerificationFailed
);
}