Fix attachment ciphertext size calculations for backup tier downloads

This commit is contained in:
trevor-signal
2025-09-22 18:14:20 -04:00
committed by GitHub
parent b2d54e1227
commit 5bfb87ef03
7 changed files with 98 additions and 37 deletions

View File

@@ -52,6 +52,7 @@ import { missingCaseError } from './util/missingCaseError.js';
import { getEnvironment, Environment } from './environment.js'; import { getEnvironment, Environment } from './environment.js';
import { isNotEmpty, toBase64, toHex } from './Bytes.js'; import { isNotEmpty, toBase64, toHex } from './Bytes.js';
import { decipherWithAesKey } from './util/decipherWithAesKey.js'; import { decipherWithAesKey } from './util/decipherWithAesKey.js';
import { MediaTier } from './types/AttachmentDownload.js';
const { ensureFile } = fsExtra; const { ensureFile } = fsExtra;
@@ -198,7 +199,12 @@ export async function encryptAttachmentV2({
); );
} }
chunkSizeChoice = isNumber(size) chunkSizeChoice = isNumber(size)
? inferChunkSize(getAttachmentCiphertextLength(size)) ? inferChunkSize(
getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
})
)
: undefined; : undefined;
incrementalDigestCreator = incrementalDigestCreator =
needIncrementalMac && chunkSizeChoice needIncrementalMac && chunkSizeChoice
@@ -664,20 +670,38 @@ export function measureSize({
return passthrough; return passthrough;
} }
export function getAttachmentCiphertextLength(plaintextLength: number): number { export function getAttachmentCiphertextSize({
const paddedPlaintextSize = logPadSize(plaintextLength); unpaddedPlaintextSize,
mediaTier,
}: {
unpaddedPlaintextSize: number;
mediaTier: MediaTier;
}): number {
const paddedSize = logPadSize(unpaddedPlaintextSize);
switch (mediaTier) {
case MediaTier.STANDARD:
return getCiphertextSize(paddedSize);
case MediaTier.BACKUP:
// objects on backup tier are doubly encrypted!
return getCiphertextSize(getCiphertextSize(paddedSize));
default:
throw missingCaseError(mediaTier);
}
}
export function getCiphertextSize(paddedPlaintextSize: number): number {
return ( return (
IV_LENGTH + IV_LENGTH +
getAesCbcCiphertextLength(paddedPlaintextSize) + getAesCbcCiphertextSize(paddedPlaintextSize) +
ATTACHMENT_MAC_LENGTH ATTACHMENT_MAC_LENGTH
); );
} }
export function getAesCbcCiphertextLength(plaintextLength: number): number { export function getAesCbcCiphertextSize(plaintextSize: number): number {
const AES_CBC_BLOCK_SIZE = 16; const AES_CBC_BLOCK_SIZE = 16;
return ( return (
(1 + Math.floor(plaintextLength / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE (1 + Math.floor(plaintextSize / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE
); );
} }

View File

@@ -23,9 +23,8 @@ import {
} from '../services/backups/index.js'; } from '../services/backups/index.js';
import { import {
type EncryptedAttachmentV2, type EncryptedAttachmentV2,
getAttachmentCiphertextLength,
getAesCbcCiphertextLength,
decryptAttachmentV2ToSink, decryptAttachmentV2ToSink,
getAttachmentCiphertextSize,
} from '../AttachmentCrypto.js'; } from '../AttachmentCrypto.js';
import { import {
getBackupMediaRootKey, getBackupMediaRootKey,
@@ -66,6 +65,7 @@ import { findRetryAfterTimeFromError } from './helpers/findRetryAfterTimeFromErr
import { BackupCredentialType } from '../types/backups.js'; import { BackupCredentialType } from '../types/backups.js';
import { supportsIncrementalMac } from '../types/MIME.js'; import { supportsIncrementalMac } from '../types/MIME.js';
import type { MIMEType } from '../types/MIME.js'; import type { MIMEType } from '../types/MIME.js';
import { MediaTier } from '../types/AttachmentDownload.js';
const log = createLogger('AttachmentBackupManager'); const log = createLogger('AttachmentBackupManager');
@@ -582,7 +582,10 @@ async function copyToBackupTier({
dependencies.backupMediaBatch, dependencies.backupMediaBatch,
'backupMediaBatch must be intialized' 'backupMediaBatch must be intialized'
); );
const ciphertextLength = getAttachmentCiphertextLength(size); const ciphertextSizeOnTransitTier = getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
});
const { responses } = await dependencies.backupMediaBatch({ const { responses } = await dependencies.backupMediaBatch({
headers: await dependencies.backupsService.credentials.getHeadersForToday( headers: await dependencies.backupsService.credentials.getHeadersForToday(
@@ -594,7 +597,7 @@ async function copyToBackupTier({
cdn: cdnNumber, cdn: cdnNumber,
key: cdnKey, key: cdnKey,
}, },
objectLength: ciphertextLength, objectLength: ciphertextSizeOnTransitTier,
mediaId, mediaId,
hmacKey: macKey, hmacKey: macKey,
encryptionKey: aesKey, encryptionKey: aesKey,
@@ -613,9 +616,17 @@ async function copyToBackupTier({
} }
// Update our local understanding of what's in the backup cdn // Update our local understanding of what's in the backup cdn
const sizeOnBackupCdn = getAesCbcCiphertextLength(ciphertextLength); const ciphertextSizeOnBackupTier = getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.BACKUP,
});
await DataWriter.saveBackupCdnObjectMetadata([ await DataWriter.saveBackupCdnObjectMetadata([
{ mediaId, cdnNumber: response.cdn, sizeOnBackupCdn }, {
mediaId,
cdnNumber: response.cdn,
sizeOnBackupCdn: ciphertextSizeOnBackupTier,
},
]); ]);
return { return {

View File

@@ -12,6 +12,7 @@ import {
type CoreAttachmentDownloadJobType, type CoreAttachmentDownloadJobType,
AttachmentDownloadUrgency, AttachmentDownloadUrgency,
coreAttachmentDownloadJobSchema, coreAttachmentDownloadJobSchema,
MediaTier,
} from '../types/AttachmentDownload.js'; } from '../types/AttachmentDownload.js';
import { import {
downloadAttachment as downloadAttachmentUtil, downloadAttachment as downloadAttachmentUtil,
@@ -52,7 +53,7 @@ import { IMAGE_WEBP } from '../types/MIME.js';
import { AttachmentDownloadSource } from '../sql/Interface.js'; import { AttachmentDownloadSource } from '../sql/Interface.js';
import { drop } from '../util/drop.js'; import { drop } from '../util/drop.js';
import { import {
getAttachmentCiphertextLength, getAttachmentCiphertextSize,
type ReencryptedAttachmentV2, type ReencryptedAttachmentV2,
} from '../AttachmentCrypto.js'; } from '../AttachmentCrypto.js';
import { safeParsePartial } from '../util/schemas.js'; import { safeParsePartial } from '../util/schemas.js';
@@ -319,7 +320,15 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, { const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, {
attachment, attachment,
attachmentType, attachmentType,
ciphertextSize: getAttachmentCiphertextLength(attachment.size), // ciphertextSize is used for backup media download progress accounting; it may not
// exactly match what we end up downloading, and that's OK (e.g. we may fallback to
// download from the transit tier, which would be a slightly smaller size)
ciphertextSize: getAttachmentCiphertextSize({
unpaddedPlaintextSize: attachment.size,
mediaTier: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA
? MediaTier.BACKUP
: MediaTier.STANDARD,
}),
contentType: attachment.contentType, contentType: attachment.contentType,
attachmentSignature: getUndownloadedAttachmentSignature(attachment), attachmentSignature: getUndownloadedAttachmentSignature(attachment),
isManualDownload, isManualDownload,

View File

@@ -44,8 +44,8 @@ import {
_generateAttachmentIv, _generateAttachmentIv,
decryptAttachmentV2, decryptAttachmentV2,
encryptAttachmentV2ToDisk, encryptAttachmentV2ToDisk,
getAesCbcCiphertextLength, getAesCbcCiphertextSize,
getAttachmentCiphertextLength, getAttachmentCiphertextSize,
splitKeys, splitKeys,
generateAttachmentKeys, generateAttachmentKeys,
type DecryptedAttachmentV2, type DecryptedAttachmentV2,
@@ -55,6 +55,7 @@ import type { AciString, PniString } from '../types/ServiceId.js';
import { createTempDir, deleteTempDir } from '../updater/common.js'; import { createTempDir, deleteTempDir } from '../updater/common.js';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
import { getPath } from '../windows/main/attachments.js'; import { getPath } from '../windows/main/attachments.js';
import { MediaTier } from '../types/AttachmentDownload.js';
const { emptyDir } = fsExtra; const { emptyDir } = fsExtra;
@@ -780,7 +781,10 @@ describe('Crypto', () => {
assert.strictEqual( assert.strictEqual(
encryptedAttachment.ciphertextSize, encryptedAttachment.ciphertextSize,
getAttachmentCiphertextLength(data.byteLength) getAttachmentCiphertextSize({
unpaddedPlaintextSize: data.byteLength,
mediaTier: MediaTier.STANDARD,
})
); );
if (overrideSize == null) { if (overrideSize == null) {
@@ -1193,7 +1197,7 @@ describe('Crypto', () => {
} }
it('calculates cipherTextLength correctly', () => { it('calculates cipherTextLength correctly', () => {
for (let i = 0; i < 128; i += 1) { for (let i = 0; i < 128; i += 1) {
assert.strictEqual(getAesCbcCiphertextLength(i), encrypt(i).length); assert.strictEqual(getAesCbcCiphertextSize(i), encrypt(i).length);
} }
}); });
}); });

View File

@@ -17,6 +17,7 @@ import {
import { import {
type AttachmentDownloadJobType, type AttachmentDownloadJobType,
AttachmentDownloadUrgency, AttachmentDownloadUrgency,
MediaTier,
} from '../../types/AttachmentDownload.js'; } from '../../types/AttachmentDownload.js';
import { DataReader, DataWriter } from '../../sql/Client.js'; import { DataReader, DataWriter } from '../../sql/Client.js';
import { DAY, MINUTE, MONTH } from '../../util/durations/index.js'; import { DAY, MINUTE, MONTH } from '../../util/durations/index.js';
@@ -29,7 +30,7 @@ import type { downloadAttachment as downloadAttachmentUtil } from '../../util/do
import { AttachmentDownloadSource } from '../../sql/Interface.js'; import { AttachmentDownloadSource } from '../../sql/Interface.js';
import { import {
generateAttachmentKeys, generateAttachmentKeys,
getAttachmentCiphertextLength, getAttachmentCiphertextSize,
} from '../../AttachmentCrypto.js'; } from '../../AttachmentCrypto.js';
import { MEBIBYTE } from '../../types/AttachmentSize.js'; import { MEBIBYTE } from '../../types/AttachmentSize.js';
import { generateAci } from '../../types/ServiceId.js'; import { generateAci } from '../../types/ServiceId.js';
@@ -58,7 +59,10 @@ function composeJob({
attachmentType: 'attachment', attachmentType: 'attachment',
attachmentSignature: `${digest}.${plaintextHash}`, attachmentSignature: `${digest}.${plaintextHash}`,
size, size,
ciphertextSize: getAttachmentCiphertextLength(size), ciphertextSize: getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
}),
contentType, contentType,
active: false, active: false,
attempts: 0, attempts: 0,

View File

@@ -22,7 +22,7 @@ import {
} from '../types/Attachment.js'; } from '../types/Attachment.js';
import * as Bytes from '../Bytes.js'; import * as Bytes from '../Bytes.js';
import { import {
getAttachmentCiphertextLength, getAttachmentCiphertextSize,
safeUnlink, safeUnlink,
splitKeys, splitKeys,
type ReencryptedAttachmentV2, type ReencryptedAttachmentV2,
@@ -142,6 +142,7 @@ export async function downloadAttachment(
let downloadResult: Awaited<ReturnType<typeof downloadToDisk>>; let downloadResult: Awaited<ReturnType<typeof downloadToDisk>>;
let downloadPath = let downloadPath =
mediaTier === MediaTier.STANDARD &&
options.variant === AttachmentVariant.Default options.variant === AttachmentVariant.Default
? attachment.downloadPath ? attachment.downloadPath
: undefined; : undefined;
@@ -170,11 +171,13 @@ export async function downloadAttachment(
} }
} }
const expectedCiphertextSize = getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier,
});
// Start over if we go over the size // Start over if we go over the size
if ( if (downloadOffset >= expectedCiphertextSize && absoluteDownloadPath) {
downloadOffset >= getAttachmentCiphertextLength(size) &&
absoluteDownloadPath
) {
log.warn('went over, retrying'); log.warn('went over, retrying');
await safeUnlink(absoluteDownloadPath); await safeUnlink(absoluteDownloadPath);
downloadOffset = 0; downloadOffset = 0;
@@ -211,7 +214,7 @@ export async function downloadAttachment(
downloadPath, downloadPath,
downloadStream, downloadStream,
onSizeUpdate: options.onSizeUpdate, onSizeUpdate: options.onSizeUpdate,
size, expectedCiphertextSize,
}); });
} else { } else {
strictAssert(mediaTier === MediaTier.BACKUP, 'backup media tier'); strictAssert(mediaTier === MediaTier.BACKUP, 'backup media tier');
@@ -256,12 +259,14 @@ export async function downloadAttachment(
downloadPath, downloadPath,
downloadOffset, downloadOffset,
onSizeUpdate: options.onSizeUpdate, onSizeUpdate: options.onSizeUpdate,
size: getAttachmentCiphertextLength( expectedCiphertextSize:
options.variant === AttachmentVariant.ThumbnailFromBackup options.variant === AttachmentVariant.ThumbnailFromBackup
? // be generous, accept downloads of up to twice what we expect for thumbnail ? getAttachmentCiphertextSize({
MAX_BACKUP_THUMBNAIL_SIZE * 2 // to be generous, we accept downloads of up to twice what we expect
: size unpaddedPlaintextSize: MAX_BACKUP_THUMBNAIL_SIZE * 2,
), mediaTier: MediaTier.BACKUP,
})
: expectedCiphertextSize,
}); });
} }
@@ -345,13 +350,13 @@ async function downloadToDisk({
downloadPath, downloadPath,
downloadStream, downloadStream,
onSizeUpdate, onSizeUpdate,
size, expectedCiphertextSize,
}: { }: {
downloadOffset?: number; downloadOffset?: number;
downloadPath?: string; downloadPath?: string;
downloadStream: Readable; downloadStream: Readable;
onSizeUpdate: (totalBytes: number) => void; onSizeUpdate: (totalBytes: number) => void;
size: number; expectedCiphertextSize: number;
}): Promise<{ absolutePath: string; downloadSize: number }> { }): Promise<{ absolutePath: string; downloadSize: number }> {
const absoluteTargetPath = downloadPath const absoluteTargetPath = downloadPath
? window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath) ? window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath)
@@ -373,7 +378,7 @@ async function downloadToDisk({
writeStream = createWriteStream(absoluteTargetPath); writeStream = createWriteStream(absoluteTargetPath);
} }
const targetSize = getAttachmentCiphertextLength(size) - downloadOffset; const targetSize = expectedCiphertextSize - downloadOffset;
let downloadSize = 0; let downloadSize = 0;
try { try {
@@ -428,7 +433,7 @@ function checkSize(expectedBytes: number) {
} }
if (totalBytes > expectedBytes) { if (totalBytes > expectedBytes) {
log.warn( log.error(
`checkSize: Received ${totalBytes} bytes, expected ${expectedBytes}` `checkSize: Received ${totalBytes} bytes, expected ${expectedBytes}`
); );
} }

View File

@@ -21,7 +21,8 @@ import { handleVideoAttachment } from './handleVideoAttachment.js';
import { isHeic, stringToMIMEType } from '../types/MIME.js'; import { isHeic, stringToMIMEType } from '../types/MIME.js';
import { ToastType } from '../types/Toast.js'; import { ToastType } from '../types/Toast.js';
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js'; import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js';
import { getAttachmentCiphertextLength } from '../AttachmentCrypto.js'; import { getAttachmentCiphertextSize } from '../AttachmentCrypto.js';
import { MediaTier } from '../types/AttachmentDownload.js';
const log = createLogger('processAttachment'); const log = createLogger('processAttachment');
@@ -85,7 +86,10 @@ function isAttachmentSizeOkay(attachment: Readonly<AttachmentType>): boolean {
const limitBytes = const limitBytes =
getMaximumOutgoingAttachmentSizeInKb(getRemoteConfigValue) * KIBIBYTE; getMaximumOutgoingAttachmentSizeInKb(getRemoteConfigValue) * KIBIBYTE;
const paddedAndEncryptedSize = getAttachmentCiphertextLength(attachment.size); const paddedAndEncryptedSize = getAttachmentCiphertextSize({
unpaddedPlaintextSize: attachment.size,
mediaTier: MediaTier.STANDARD,
});
if (paddedAndEncryptedSize > limitBytes) { if (paddedAndEncryptedSize > limitBytes) {
window.reduxActions.toast.showToast({ window.reduxActions.toast.showToast({
toastType: ToastType.FileSize, toastType: ToastType.FileSize,