// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable max-classes-per-file */ import { existsSync } from 'node:fs'; import { PassThrough } from 'node:stream'; import * as durations from '../util/durations'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; import * as Errors from '../types/errors'; import { redactGenericText } from '../util/privacy'; import { JobManager, type JobManagerParamsType, type JobManagerJobResultType, } from './JobManager'; import { deriveBackupMediaKeyMaterial } from '../Crypto'; import { strictAssert } from '../util/assert'; import { type BackupsService, backupsService } from '../services/backups'; import { type EncryptedAttachmentV2, getAttachmentCiphertextLength, getAesCbcCiphertextLength, decryptAttachmentV2ToSink, ReencyptedDigestMismatchError, } from '../AttachmentCrypto'; import { getBackupKey } from '../services/backups/crypto'; import type { AttachmentBackupJobType, CoreAttachmentBackupJobType, } from '../types/AttachmentBackup'; import { isInCall as isInCallSelector } from '../state/selectors/calling'; import { encryptAndUploadAttachment } from '../util/uploadAttachment'; import { getMediaIdFromMediaName } from '../services/backups/util/mediaId'; import { fromBase64 } from '../Bytes'; import type { WebAPIType } from '../textsecure/WebAPI'; import { mightStillBeOnTransitTier } from '../types/Attachment'; const MAX_CONCURRENT_JOBS = 3; const RETRY_CONFIG = { // As long as we have the file locally, we're always going to keep trying maxAttempts: Infinity, backoffConfig: { // 1 minute, 5 minutes, 25 minutes, every hour multiplier: 5, firstBackoffs: [durations.MINUTE], maxBackoffTime: durations.HOUR, }, }; export class AttachmentBackupManager extends JobManager { private static _instance: AttachmentBackupManager | undefined; static defaultParams: JobManagerParamsType = { markAllJobsInactive: dataInterface.markAllAttachmentBackupJobsInactive, saveJob: dataInterface.saveAttachmentBackupJob, removeJob: dataInterface.removeAttachmentBackupJob, getNextJobs: dataInterface.getNextAttachmentBackupJobs, runJob: runAttachmentBackupJob, shouldHoldOffOnStartingQueuedJobs: () => { const reduxState = window.reduxStore?.getState(); if (reduxState) { return isInCallSelector(reduxState); } return false; }, getJobId, getJobIdForLogging, getRetryConfig: () => RETRY_CONFIG, maxConcurrentJobs: MAX_CONCURRENT_JOBS, }; override logPrefix = 'AttachmentBackupManager'; static get instance(): AttachmentBackupManager { if (!AttachmentBackupManager._instance) { AttachmentBackupManager._instance = new AttachmentBackupManager( AttachmentBackupManager.defaultParams ); } return AttachmentBackupManager._instance; } static async start(): Promise { log.info('AttachmentBackupManager/starting'); await AttachmentBackupManager.instance.start(); } static async stop(): Promise { log.info('AttachmentBackupManager/stopping'); return AttachmentBackupManager._instance?.stop(); } static async addJob(newJob: CoreAttachmentBackupJobType): Promise { return AttachmentBackupManager.instance.addJob(newJob); } } function getJobId(job: CoreAttachmentBackupJobType): string { return job.mediaName; } function getJobIdForLogging(job: CoreAttachmentBackupJobType): string { return redactGenericText(job.mediaName); } /** * Backup-specific methods */ class AttachmentPermanentlyMissingError extends Error {} class FileNotFoundOnTransitTierError extends Error {} type RunAttachmentBackupJobDependenciesType = { getAbsoluteAttachmentPath: typeof window.Signal.Migrations.getAbsoluteAttachmentPath; backupMediaBatch?: WebAPIType['backupMediaBatch']; backupsService: BackupsService; encryptAndUploadAttachment: typeof encryptAndUploadAttachment; decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; }; export async function runAttachmentBackupJob( job: AttachmentBackupJobType, _isLastAttempt: boolean, dependencies: RunAttachmentBackupJobDependenciesType = { getAbsoluteAttachmentPath: window.Signal.Migrations.getAbsoluteAttachmentPath, backupsService, backupMediaBatch: window.textsecure.server?.backupMediaBatch, encryptAndUploadAttachment, decryptAttachmentV2ToSink, } ): Promise { const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentBackupManager/runAttachmentBackupJob/${jobIdForLogging}`; try { await runAttachmentBackupJobInner(job, dependencies); return { status: 'finished' }; } catch (error) { log.error( `${logId}: Failed to backup attachment, attempt ${job.attempts}`, Errors.toLogFormat(error) ); if (error instanceof AttachmentPermanentlyMissingError) { log.error(`${logId}: Attachment unable to be found, giving up on job`); return { status: 'finished' }; } if (error instanceof ReencyptedDigestMismatchError) { log.error( `${logId}: Unable to reencrypt to match same digest; content must have changed` ); return { status: 'finished' }; } return { status: 'retry' }; } } async function runAttachmentBackupJobInner( job: AttachmentBackupJobType, dependencies: RunAttachmentBackupJobDependenciesType ): Promise { const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(mediaName:${jobIdForLogging})`; log.info(`${logId}: starting`); const { mediaName, type } = job; // TODO (DESKTOP-6913): generate & upload thumbnail strictAssert( type === 'standard', 'Only standard uploads are currently supported' ); const { path, transitCdnInfo, iv, digest, keys, size, version, localKey } = job.data; const mediaId = getMediaIdFromMediaName(mediaName); const backupKeyMaterial = deriveBackupMediaKeyMaterial( getBackupKey(), mediaId.bytes ); const { isInBackupTier } = await dependencies.backupsService.getBackupCdnInfo( mediaId.string ); if (isInBackupTier) { log.info(`${logId}: object already in backup tier, done`); return; } if (transitCdnInfo) { const { cdnKey: transitCdnKey, cdnNumber: transitCdnNumber, uploadTimestamp: transitCdnUploadTimestamp, } = transitCdnInfo; if (mightStillBeOnTransitTier(transitCdnInfo)) { try { await copyToBackupTier({ cdnKey: transitCdnKey, cdnNumber: transitCdnNumber, size, mediaId: mediaId.string, ...backupKeyMaterial, dependencies, }); log.info(`${logId}: copied to backup tier successfully`); return; } catch (e) { if (e instanceof FileNotFoundOnTransitTierError) { log.info( `${logId}: file not found on transit tier, uploadTimestamp: ${transitCdnUploadTimestamp}` ); } else { log.error( `${logId}: error copying to backup tier`, Errors.toLogFormat(e) ); throw e; } } } } if (!path) { throw new AttachmentPermanentlyMissingError( 'File not on transit tier and no path property' ); } const absolutePath = dependencies.getAbsoluteAttachmentPath(path); if (!existsSync(absolutePath)) { throw new AttachmentPermanentlyMissingError('No file at provided path'); } log.info(`${logId}: uploading to transit tier`); const uploadResult = await uploadToTransitTier({ absolutePath, version, localKey, size, keys, iv, digest, logPrefix: logId, dependencies, }); log.info(`${logId}: copying to backup tier`); await copyToBackupTier({ cdnKey: uploadResult.cdnKey, cdnNumber: uploadResult.cdnNumber, size, mediaId: mediaId.string, ...backupKeyMaterial, dependencies, }); } type UploadResponseType = { cdnKey: string; cdnNumber: number; encrypted: EncryptedAttachmentV2; }; async function uploadToTransitTier({ absolutePath, keys, version, localKey, size, iv, digest, logPrefix, dependencies, }: { absolutePath: string; iv: string; digest: string; keys: string; version?: 2; localKey?: string; size: number; logPrefix: string; dependencies: { decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; encryptAndUploadAttachment: typeof encryptAndUploadAttachment; }; }): Promise { try { if (version === 2) { strictAssert( localKey != null, 'Missing localKey for version 2 attachment' ); const sink = new PassThrough(); // This `Promise.all` is chaining two separate pipelines via // a pass-through `sink`. const [, result] = await Promise.all([ dependencies.decryptAttachmentV2ToSink( { idForLogging: 'uploadToTransitTier', ciphertextPath: absolutePath, keysBase64: localKey, size, isLocal: true, }, sink ), dependencies.encryptAndUploadAttachment({ plaintext: { stream: sink }, keys: fromBase64(keys), dangerousIv: { reason: 'reencrypting-for-backup', iv: fromBase64(iv), digestToMatch: fromBase64(digest), }, uploadType: 'backup', }), ]); return result; } // Legacy attachments return dependencies.encryptAndUploadAttachment({ plaintext: { absolutePath }, keys: fromBase64(keys), dangerousIv: { reason: 'reencrypting-for-backup', iv: fromBase64(iv), digestToMatch: fromBase64(digest), }, uploadType: 'backup', }); } catch (error) { log.error( `${logPrefix}/uploadToTransitTier: Error while encrypting and uploading`, Errors.toLogFormat(error) ); throw error; } } export const FILE_NOT_FOUND_ON_TRANSIT_TIER_STATUS = 410; async function copyToBackupTier({ cdnNumber, cdnKey, size, mediaId, macKey, aesKey, iv, dependencies, }: { cdnNumber: number; cdnKey: string; size: number; mediaId: string; macKey: Uint8Array; aesKey: Uint8Array; iv: Uint8Array; dependencies: { backupMediaBatch?: WebAPIType['backupMediaBatch']; backupsService: BackupsService; }; }): Promise<{ cdnNumberOnBackup: number }> { strictAssert( dependencies.backupMediaBatch, 'backupMediaBatch must be intialized' ); const ciphertextLength = getAttachmentCiphertextLength(size); const { responses } = await dependencies.backupMediaBatch({ headers: await dependencies.backupsService.credentials.getHeadersForToday(), items: [ { sourceAttachment: { cdn: cdnNumber, key: cdnKey, }, objectLength: ciphertextLength, mediaId, hmacKey: macKey, encryptionKey: aesKey, iv, }, ], }); const response = responses[0]; if (!response.isSuccess) { if (response.status === FILE_NOT_FOUND_ON_TRANSIT_TIER_STATUS) { throw new FileNotFoundOnTransitTierError(); } throw new Error( `copyToBackupTier failed: ${response.failureReason}, code: ${response.status}` ); } // Update our local understanding of what's in the backup cdn const sizeOnBackupCdn = getAesCbcCiphertextLength(ciphertextLength); await window.Signal.Data.saveBackupCdnObjectMetadata([ { mediaId, cdnNumber: response.cdn, sizeOnBackupCdn }, ]); return { cdnNumberOnBackup: response.cdn, }; }