mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-02 14:21:05 +01:00
Discard invalid incrementalMacs
This commit is contained in:
@@ -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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -856,7 +856,7 @@ describe('Crypto', () => {
|
|||||||
plaintextHash,
|
plaintextHash,
|
||||||
modifyIncrementalMac: true,
|
modifyIncrementalMac: true,
|
||||||
}),
|
}),
|
||||||
/Corrupted/
|
/^Corrupted input data/
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
unlinkSync(sourcePath);
|
unlinkSync(sourcePath);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user