signal-desktop/ts/services/backups/util/filePointers.ts

485 lines
14 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Long from 'long';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { omit } from 'lodash';
import {
APPLICATION_OCTET_STREAM,
stringToMIMEType,
} from '../../../types/MIME';
import * as log from '../../../logging/log';
import {
type AttachmentType,
isDownloadableFromTransitTier,
isDownloadableFromBackupTier,
isAttachmentLocallySaved,
type AttachmentDownloadableFromTransitTier,
type AttachmentDownloadableFromBackupTier,
isDecryptable,
isReencryptableToSameDigest,
isReencryptableWithNewEncryptionInfo,
type ReencryptableAttachment,
} from '../../../types/Attachment';
import { Backups, SignalService } from '../../../protobuf';
import * as Bytes from '../../../Bytes';
import { getTimestampFromLong } from '../../../util/timestampLongUtils';
import { strictAssert } from '../../../util/assert';
import type { CoreAttachmentBackupJobType } from '../../../types/AttachmentBackup';
import {
type GetBackupCdnInfoType,
getMediaIdFromMediaName,
getMediaNameForAttachment,
getMediaNameFromDigest,
type BackupCdnInfoType,
} from './mediaId';
import { redactGenericText } from '../../../util/privacy';
import { missingCaseError } from '../../../util/missingCaseError';
import { toLogFormat } from '../../../types/errors';
import { bytesToUuid } from '../../../util/uuidToBytes';
import { createName } from '../../../util/attachmentPath';
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
import type { ReencryptionInfo } from '../../../AttachmentCrypto';
export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer,
// Only for testing
{ _createName: doCreateName = createName } = {}
): AttachmentType {
const {
contentType,
width,
height,
fileName,
caption,
blurHash,
incrementalMac,
incrementalMacChunkSize,
attachmentLocator,
backupLocator,
invalidAttachmentLocator,
} = filePointer;
const commonProps: Omit<AttachmentType, 'size'> = {
contentType: contentType
? stringToMIMEType(contentType)
: APPLICATION_OCTET_STREAM,
width: width ?? undefined,
height: height ?? undefined,
fileName: fileName ?? undefined,
caption: caption ?? undefined,
blurHash: blurHash ?? undefined,
incrementalMac: incrementalMac?.length
? Bytes.toBase64(incrementalMac)
: undefined,
incrementalMacChunkSize: incrementalMacChunkSize ?? undefined,
downloadPath: doCreateName(),
};
if (attachmentLocator) {
const { cdnKey, cdnNumber, key, digest, uploadTimestamp, size } =
attachmentLocator;
return {
...commonProps,
size: size ?? 0,
cdnKey: cdnKey ?? undefined,
cdnNumber: cdnNumber ?? undefined,
key: key?.length ? Bytes.toBase64(key) : undefined,
digest: digest?.length ? Bytes.toBase64(digest) : undefined,
uploadTimestamp: uploadTimestamp
? getTimestampFromLong(uploadTimestamp)
: undefined,
};
}
if (backupLocator) {
const {
mediaName,
cdnNumber,
key,
digest,
size,
transitCdnKey,
transitCdnNumber,
} = backupLocator;
return {
...commonProps,
cdnKey: transitCdnKey ?? undefined,
cdnNumber: transitCdnNumber ?? undefined,
key: key?.length ? Bytes.toBase64(key) : undefined,
digest: digest?.length ? Bytes.toBase64(digest) : undefined,
size: size ?? 0,
backupLocator: mediaName
? {
mediaName,
cdnNumber: cdnNumber ?? undefined,
}
: undefined,
};
}
if (invalidAttachmentLocator) {
return {
...omit(commonProps, 'downloadPath'),
error: true,
size: 0,
};
}
throw new Error('convertFilePointerToAttachment: mising locator');
}
export function convertBackupMessageAttachmentToAttachment(
messageAttachment: Backups.IMessageAttachment
): AttachmentType | null {
const { clientUuid } = messageAttachment;
if (!messageAttachment.pointer) {
return null;
}
const result = {
...convertFilePointerToAttachment(messageAttachment.pointer),
clientUuid: clientUuid ? bytesToUuid(clientUuid) : undefined,
};
switch (messageAttachment.flag) {
case Backups.MessageAttachment.Flag.VOICE_MESSAGE:
result.flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
break;
case Backups.MessageAttachment.Flag.BORDERLESS:
result.flags = SignalService.AttachmentPointer.Flags.BORDERLESS;
break;
case Backups.MessageAttachment.Flag.GIF:
result.flags = SignalService.AttachmentPointer.Flags.GIF;
break;
case Backups.MessageAttachment.Flag.NONE:
case null:
case undefined:
result.flags = undefined;
break;
default:
throw missingCaseError(messageAttachment.flag);
}
return result;
}
export async function getFilePointerForAttachment({
attachment,
backupLevel,
getBackupCdnInfo,
}: {
attachment: Readonly<AttachmentType>;
backupLevel: BackupLevel;
getBackupCdnInfo: GetBackupCdnInfoType;
}): Promise<{
filePointer: Backups.FilePointer;
updatedAttachment?: AttachmentType;
}> {
const filePointerRootProps = new Backups.FilePointer({
contentType: attachment.contentType,
incrementalMac: attachment.incrementalMac
? Bytes.fromBase64(attachment.incrementalMac)
: undefined,
incrementalMacChunkSize: attachment.incrementalMacChunkSize,
fileName: attachment.fileName,
width: attachment.width,
height: attachment.height,
caption: attachment.caption,
blurHash: attachment.blurHash,
});
const logId = `getFilePointerForAttachment(${redactGenericText(
attachment.digest ?? ''
)})`;
if (attachment.size == null) {
log.warn(`${logId}: attachment had nullish size, dropping`);
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
if (!isAttachmentLocallySaved(attachment)) {
// 1. If the attachment is undownloaded, we cannot trust its digest / mediaName. Thus,
// we only include a BackupLocator if this attachment already had one (e.g. we
// restored it from a backup and it had a BackupLocator then, which means we have at
// one point in the past verified the digest).
if (
isDownloadableFromBackupTier(attachment) &&
backupLevel === BackupLevel.Media
) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator(attachment),
}),
};
}
// 2. Otherwise, we only return the transit CDN info via AttachmentLocator
if (isDownloadableFromTransitTier(attachment)) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
attachmentLocator: getAttachmentLocator(attachment),
}),
};
}
// 3. Otherwise, we don't have the attachment, and we don't have info to download it
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
// The attachment is locally saved
if (backupLevel !== BackupLevel.Media) {
// 1. If we have information to donwnload the file from the transit tier, great, let's
// just create an attachmentLocator so the restorer can try to download from the
// transit tier
if (isDownloadableFromTransitTier(attachment)) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
attachmentLocator: getAttachmentLocator(attachment),
}),
};
}
// 2. Otherwise, we have the attachment locally, but we don't have information to put
// in the backup proto to allow the restorer to download it. (This shouldn't
// happen!)
log.warn(
`${logId}: Attachment is downloaded but we lack information to decrypt it`
);
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
// From here on, this attachment is headed to (or already on) the backup tier!
const mediaNameForCurrentVersionOfAttachment = attachment.digest
? getMediaNameForAttachment(attachment)
: undefined;
const backupCdnInfo: BackupCdnInfoType =
mediaNameForCurrentVersionOfAttachment
? await getBackupCdnInfo(
getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string
)
: { isInBackupTier: false };
// If we have key & digest for this attachment and it's already on backup tier, we can
// reference it
if (isDecryptable(attachment) && backupCdnInfo.isInBackupTier) {
strictAssert(mediaNameForCurrentVersionOfAttachment, 'must exist');
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator({
...attachment,
backupLocator: {
mediaName: mediaNameForCurrentVersionOfAttachment,
cdnNumber: backupCdnInfo.isInBackupTier
? backupCdnInfo.cdnNumber
: undefined,
},
}),
}),
};
}
let reencryptableAttachment: ReencryptableAttachment;
try {
reencryptableAttachment = await ensureAttachmentIsReencryptable(attachment);
} catch (e) {
log.warn('Unable to ensure attachment is reencryptable', toLogFormat(e));
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
invalidAttachmentLocator: getInvalidAttachmentLocator(),
}),
};
}
// If we've confirmed that we can re-encrypt this attachment to the same digest, we can
// generate a backupLocator (and upload the file)
if (isReencryptableToSameDigest(reencryptableAttachment)) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator({
...reencryptableAttachment,
backupLocator: {
mediaName: getMediaNameFromDigest(reencryptableAttachment.digest),
cdnNumber: backupCdnInfo.isInBackupTier
? backupCdnInfo.cdnNumber
: undefined,
},
}),
}),
updatedAttachment: reencryptableAttachment,
};
}
strictAssert(
reencryptableAttachment.reencryptionInfo,
'Reencryption info must exist if not reencryptable to original digest'
);
const mediaNameForNewEncryptionInfo = getMediaNameFromDigest(
reencryptableAttachment.reencryptionInfo.digest
);
const backupCdnInfoForNewEncryptionInfo = await getBackupCdnInfo(
getMediaIdFromMediaName(mediaNameForNewEncryptionInfo).string
);
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator({
size: reencryptableAttachment.size,
...reencryptableAttachment.reencryptionInfo,
backupLocator: {
mediaName: mediaNameForNewEncryptionInfo,
cdnNumber: backupCdnInfoForNewEncryptionInfo.isInBackupTier
? backupCdnInfoForNewEncryptionInfo.cdnNumber
: undefined,
},
}),
}),
updatedAttachment: reencryptableAttachment,
};
}
function getAttachmentLocator(
attachment: AttachmentDownloadableFromTransitTier
) {
return new Backups.FilePointer.AttachmentLocator({
cdnKey: attachment.cdnKey,
cdnNumber: attachment.cdnNumber,
uploadTimestamp: attachment.uploadTimestamp
? Long.fromNumber(attachment.uploadTimestamp)
: null,
digest: Bytes.fromBase64(attachment.digest),
key: Bytes.fromBase64(attachment.key),
size: attachment.size,
});
}
function getBackupLocator(
attachment: Pick<
AttachmentDownloadableFromBackupTier,
'backupLocator' | 'digest' | 'key' | 'size' | 'cdnKey' | 'cdnNumber'
>
) {
return new Backups.FilePointer.BackupLocator({
mediaName: attachment.backupLocator.mediaName,
cdnNumber: attachment.backupLocator.cdnNumber,
digest: Bytes.fromBase64(attachment.digest),
key: Bytes.fromBase64(attachment.key),
size: attachment.size,
transitCdnKey: attachment.cdnKey,
transitCdnNumber: attachment.cdnNumber,
});
}
function getInvalidAttachmentLocator() {
return new Backups.FilePointer.InvalidAttachmentLocator();
}
export async function maybeGetBackupJobForAttachmentAndFilePointer({
attachment,
filePointer,
getBackupCdnInfo,
messageReceivedAt,
}: {
attachment: AttachmentType;
filePointer: Backups.FilePointer;
getBackupCdnInfo: GetBackupCdnInfoType;
messageReceivedAt: number;
}): Promise<CoreAttachmentBackupJobType | null> {
if (!filePointer.backupLocator) {
return null;
}
const { mediaName } = filePointer.backupLocator;
strictAssert(mediaName, 'mediaName must exist');
const { isInBackupTier } = await getBackupCdnInfo(
getMediaIdFromMediaName(mediaName).string
);
if (isInBackupTier) {
return null;
}
strictAssert(
isAttachmentLocallySaved(attachment),
'Attachment must be saved locally for it to be backed up'
);
let encryptionInfo: ReencryptionInfo | undefined;
if (isReencryptableToSameDigest(attachment)) {
encryptionInfo = {
iv: attachment.iv,
key: attachment.key,
digest: attachment.digest,
};
} else {
strictAssert(
isReencryptableWithNewEncryptionInfo(attachment) === true,
'must have new encryption info'
);
encryptionInfo = attachment.reencryptionInfo;
}
strictAssert(
filePointer.backupLocator.digest,
'digest must exist on backupLocator'
);
strictAssert(
encryptionInfo.digest === Bytes.toBase64(filePointer.backupLocator.digest),
'digest on job and backupLocator must match'
);
const { path, contentType, size, uploadTimestamp, version, localKey } =
attachment;
const { transitCdnKey, transitCdnNumber } = filePointer.backupLocator;
return {
mediaName,
receivedAt: messageReceivedAt,
type: 'standard',
data: {
path,
contentType,
keys: encryptionInfo.key,
digest: encryptionInfo.digest,
iv: encryptionInfo.iv,
size,
version,
localKey,
transitCdnInfo:
transitCdnKey != null && transitCdnNumber != null
? {
cdnKey: transitCdnKey,
cdnNumber: transitCdnNumber,
uploadTimestamp,
}
: undefined,
},
};
}