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 { isNotEmpty, toBase64, toHex } from './Bytes.js';
import { decipherWithAesKey } from './util/decipherWithAesKey.js';
import { MediaTier } from './types/AttachmentDownload.js';
const { ensureFile } = fsExtra;
@@ -198,7 +199,12 @@ export async function encryptAttachmentV2({
);
}
chunkSizeChoice = isNumber(size)
? inferChunkSize(getAttachmentCiphertextLength(size))
? inferChunkSize(
getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
})
)
: undefined;
incrementalDigestCreator =
needIncrementalMac && chunkSizeChoice
@@ -664,20 +670,38 @@ export function measureSize({
return passthrough;
}
export function getAttachmentCiphertextLength(plaintextLength: number): number {
const paddedPlaintextSize = logPadSize(plaintextLength);
export function getAttachmentCiphertextSize({
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 (
IV_LENGTH +
getAesCbcCiphertextLength(paddedPlaintextSize) +
getAesCbcCiphertextSize(paddedPlaintextSize) +
ATTACHMENT_MAC_LENGTH
);
}
export function getAesCbcCiphertextLength(plaintextLength: number): number {
export function getAesCbcCiphertextSize(plaintextSize: number): number {
const AES_CBC_BLOCK_SIZE = 16;
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';
import {
type EncryptedAttachmentV2,
getAttachmentCiphertextLength,
getAesCbcCiphertextLength,
decryptAttachmentV2ToSink,
getAttachmentCiphertextSize,
} from '../AttachmentCrypto.js';
import {
getBackupMediaRootKey,
@@ -66,6 +65,7 @@ import { findRetryAfterTimeFromError } from './helpers/findRetryAfterTimeFromErr
import { BackupCredentialType } from '../types/backups.js';
import { supportsIncrementalMac } from '../types/MIME.js';
import type { MIMEType } from '../types/MIME.js';
import { MediaTier } from '../types/AttachmentDownload.js';
const log = createLogger('AttachmentBackupManager');
@@ -582,7 +582,10 @@ async function copyToBackupTier({
dependencies.backupMediaBatch,
'backupMediaBatch must be intialized'
);
const ciphertextLength = getAttachmentCiphertextLength(size);
const ciphertextSizeOnTransitTier = getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
});
const { responses } = await dependencies.backupMediaBatch({
headers: await dependencies.backupsService.credentials.getHeadersForToday(
@@ -594,7 +597,7 @@ async function copyToBackupTier({
cdn: cdnNumber,
key: cdnKey,
},
objectLength: ciphertextLength,
objectLength: ciphertextSizeOnTransitTier,
mediaId,
hmacKey: macKey,
encryptionKey: aesKey,
@@ -613,9 +616,17 @@ async function copyToBackupTier({
}
// 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([
{ mediaId, cdnNumber: response.cdn, sizeOnBackupCdn },
{
mediaId,
cdnNumber: response.cdn,
sizeOnBackupCdn: ciphertextSizeOnBackupTier,
},
]);
return {

View File

@@ -12,6 +12,7 @@ import {
type CoreAttachmentDownloadJobType,
AttachmentDownloadUrgency,
coreAttachmentDownloadJobSchema,
MediaTier,
} from '../types/AttachmentDownload.js';
import {
downloadAttachment as downloadAttachmentUtil,
@@ -52,7 +53,7 @@ import { IMAGE_WEBP } from '../types/MIME.js';
import { AttachmentDownloadSource } from '../sql/Interface.js';
import { drop } from '../util/drop.js';
import {
getAttachmentCiphertextLength,
getAttachmentCiphertextSize,
type ReencryptedAttachmentV2,
} from '../AttachmentCrypto.js';
import { safeParsePartial } from '../util/schemas.js';
@@ -319,7 +320,15 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, {
attachment,
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,
attachmentSignature: getUndownloadedAttachmentSignature(attachment),
isManualDownload,

View File

@@ -44,8 +44,8 @@ import {
_generateAttachmentIv,
decryptAttachmentV2,
encryptAttachmentV2ToDisk,
getAesCbcCiphertextLength,
getAttachmentCiphertextLength,
getAesCbcCiphertextSize,
getAttachmentCiphertextSize,
splitKeys,
generateAttachmentKeys,
type DecryptedAttachmentV2,
@@ -55,6 +55,7 @@ import type { AciString, PniString } from '../types/ServiceId.js';
import { createTempDir, deleteTempDir } from '../updater/common.js';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
import { getPath } from '../windows/main/attachments.js';
import { MediaTier } from '../types/AttachmentDownload.js';
const { emptyDir } = fsExtra;
@@ -780,7 +781,10 @@ describe('Crypto', () => {
assert.strictEqual(
encryptedAttachment.ciphertextSize,
getAttachmentCiphertextLength(data.byteLength)
getAttachmentCiphertextSize({
unpaddedPlaintextSize: data.byteLength,
mediaTier: MediaTier.STANDARD,
})
);
if (overrideSize == null) {
@@ -1193,7 +1197,7 @@ describe('Crypto', () => {
}
it('calculates cipherTextLength correctly', () => {
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 {
type AttachmentDownloadJobType,
AttachmentDownloadUrgency,
MediaTier,
} from '../../types/AttachmentDownload.js';
import { DataReader, DataWriter } from '../../sql/Client.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 {
generateAttachmentKeys,
getAttachmentCiphertextLength,
getAttachmentCiphertextSize,
} from '../../AttachmentCrypto.js';
import { MEBIBYTE } from '../../types/AttachmentSize.js';
import { generateAci } from '../../types/ServiceId.js';
@@ -58,7 +59,10 @@ function composeJob({
attachmentType: 'attachment',
attachmentSignature: `${digest}.${plaintextHash}`,
size,
ciphertextSize: getAttachmentCiphertextLength(size),
ciphertextSize: getAttachmentCiphertextSize({
unpaddedPlaintextSize: size,
mediaTier: MediaTier.STANDARD,
}),
contentType,
active: false,
attempts: 0,

View File

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