Convert attachments to filePointers for backup export
This commit is contained in:
parent
ad94fef92d
commit
6f7545926a
10 changed files with 876 additions and 194 deletions
|
@ -11,7 +11,7 @@ import {
|
||||||
randomBytes,
|
randomBytes,
|
||||||
} from 'crypto';
|
} from 'crypto';
|
||||||
import type { Decipher, Hash, Hmac } from 'crypto';
|
import type { Decipher, Hash, Hmac } from 'crypto';
|
||||||
import { Transform } from 'stream';
|
import { PassThrough, Transform, type Writable } from 'stream';
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { ensureFile } from 'fs-extra';
|
import { ensureFile } from 'fs-extra';
|
||||||
import * as log from './logging/log';
|
import * as log from './logging/log';
|
||||||
|
@ -45,7 +45,6 @@ export function _generateAttachmentIv(): Uint8Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncryptedAttachmentV2 = {
|
export type EncryptedAttachmentV2 = {
|
||||||
path: string;
|
|
||||||
digest: Uint8Array;
|
digest: Uint8Array;
|
||||||
plaintextHash: string;
|
plaintextHash: string;
|
||||||
};
|
};
|
||||||
|
@ -55,24 +54,55 @@ export type DecryptedAttachmentV2 = {
|
||||||
plaintextHash: string;
|
plaintextHash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EncryptAttachmentV2PropsType = {
|
||||||
|
keys: Readonly<Uint8Array>;
|
||||||
|
plaintextAbsolutePath: string;
|
||||||
|
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
||||||
|
dangerousTestOnlySkipPadding?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function encryptAttachmentV2ToDisk(
|
||||||
|
args: EncryptAttachmentV2PropsType
|
||||||
|
): Promise<EncryptedAttachmentV2 & { path: string }> {
|
||||||
|
// Create random output file
|
||||||
|
const relativeTargetPath = getRelativePath(createName());
|
||||||
|
const absoluteTargetPath =
|
||||||
|
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||||
|
|
||||||
|
let writeFd;
|
||||||
|
let encryptResult: EncryptedAttachmentV2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureFile(absoluteTargetPath);
|
||||||
|
writeFd = await open(absoluteTargetPath, 'w');
|
||||||
|
encryptResult = await encryptAttachmentV2({
|
||||||
|
...args,
|
||||||
|
sink: writeFd.createWriteStream(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
safeUnlinkSync(absoluteTargetPath);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await writeFd?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...encryptResult,
|
||||||
|
path: relativeTargetPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function encryptAttachmentV2({
|
export async function encryptAttachmentV2({
|
||||||
keys,
|
keys,
|
||||||
plaintextAbsolutePath,
|
plaintextAbsolutePath,
|
||||||
dangerousTestOnlyIv,
|
dangerousTestOnlyIv,
|
||||||
dangerousTestOnlySkipPadding = false,
|
dangerousTestOnlySkipPadding = false,
|
||||||
}: {
|
sink,
|
||||||
keys: Readonly<Uint8Array>;
|
}: EncryptAttachmentV2PropsType & {
|
||||||
plaintextAbsolutePath: string;
|
sink?: Writable;
|
||||||
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
|
||||||
dangerousTestOnlySkipPadding?: boolean;
|
|
||||||
}): Promise<EncryptedAttachmentV2> {
|
}): Promise<EncryptedAttachmentV2> {
|
||||||
const logId = 'encryptAttachmentV2';
|
const logId = 'encryptAttachmentV2';
|
||||||
|
|
||||||
// Create random output file
|
|
||||||
const relativeTargetPath = getRelativePath(createName());
|
|
||||||
const absoluteTargetPath =
|
|
||||||
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
|
||||||
|
|
||||||
const { aesKey, macKey } = splitKeys(keys);
|
const { aesKey, macKey } = splitKeys(keys);
|
||||||
|
|
||||||
if (dangerousTestOnlyIv && window.getEnvironment() !== Environment.Test) {
|
if (dangerousTestOnlyIv && window.getEnvironment() !== Environment.Test) {
|
||||||
|
@ -92,19 +122,12 @@ export async function encryptAttachmentV2({
|
||||||
const digest = createHash(HashType.size256);
|
const digest = createHash(HashType.size256);
|
||||||
|
|
||||||
let readFd;
|
let readFd;
|
||||||
let writeFd;
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
readFd = await open(plaintextAbsolutePath, 'r');
|
readFd = await open(plaintextAbsolutePath, 'r');
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await ensureFile(absoluteTargetPath);
|
|
||||||
writeFd = await open(absoluteTargetPath, 'w');
|
|
||||||
} catch (cause) {
|
|
||||||
throw new Error(`${logId}: Failed to create write path`, { cause });
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline(
|
await pipeline(
|
||||||
[
|
[
|
||||||
|
@ -115,7 +138,7 @@ export async function encryptAttachmentV2({
|
||||||
prependIv(iv),
|
prependIv(iv),
|
||||||
appendMacStream(macKey),
|
appendMacStream(macKey),
|
||||||
peekAndUpdateHash(digest),
|
peekAndUpdateHash(digest),
|
||||||
writeFd.createWriteStream(),
|
sink ?? new PassThrough().resume(),
|
||||||
].filter(isNotNil)
|
].filter(isNotNil)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -123,10 +146,9 @@ export async function encryptAttachmentV2({
|
||||||
`${logId}: Failed to encrypt attachment`,
|
`${logId}: Failed to encrypt attachment`,
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
safeUnlinkSync(absoluteTargetPath);
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.all([readFd?.close(), writeFd?.close()]);
|
await readFd?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ourPlaintextHash = plaintextHash.digest('hex');
|
const ourPlaintextHash = plaintextHash.digest('hex');
|
||||||
|
@ -143,7 +165,6 @@ export async function encryptAttachmentV2({
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: relativeTargetPath,
|
|
||||||
digest: ourDigest,
|
digest: ourDigest,
|
||||||
plaintextHash: ourPlaintextHash,
|
plaintextHash: ourPlaintextHash,
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,6 +8,9 @@ const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
|
||||||
export function fromBase64(value: string): Uint8Array {
|
export function fromBase64(value: string): Uint8Array {
|
||||||
return bytes.fromBase64(value);
|
return bytes.fromBase64(value);
|
||||||
}
|
}
|
||||||
|
export function fromBase64url(value: string): Uint8Array {
|
||||||
|
return bytes.fromBase64url(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function fromHex(value: string): Uint8Array {
|
export function fromHex(value: string): Uint8Array {
|
||||||
return bytes.fromHex(value);
|
return bytes.fromHex(value);
|
||||||
|
|
|
@ -8,6 +8,10 @@ export class Bytes {
|
||||||
return Buffer.from(value, 'base64');
|
return Buffer.from(value, 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fromBase64url(value: string): Uint8Array {
|
||||||
|
return Buffer.from(value, 'base64url');
|
||||||
|
}
|
||||||
|
|
||||||
public fromHex(value: string): Uint8Array {
|
public fromHex(value: string): Uint8Array {
|
||||||
return Buffer.from(value, 'hex');
|
return Buffer.from(value, 'hex');
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client';
|
import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client';
|
||||||
|
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import pTimeout from 'p-timeout';
|
import pTimeout from 'p-timeout';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
@ -82,6 +83,13 @@ import {
|
||||||
numberToAddressType,
|
numberToAddressType,
|
||||||
numberToPhoneType,
|
numberToPhoneType,
|
||||||
} from '../../types/EmbeddedContact';
|
} from '../../types/EmbeddedContact';
|
||||||
|
import {
|
||||||
|
isVoiceMessage,
|
||||||
|
type AttachmentType,
|
||||||
|
isGIF,
|
||||||
|
isDownloaded,
|
||||||
|
} from '../../types/Attachment';
|
||||||
|
import { convertAttachmentToFilePointer } from './util/filePointers';
|
||||||
|
|
||||||
const MAX_CONCURRENCY = 10;
|
const MAX_CONCURRENCY = 10;
|
||||||
|
|
||||||
|
@ -116,14 +124,13 @@ export class BackupExportStream extends Readable {
|
||||||
private nextRecipientId = 0;
|
private nextRecipientId = 0;
|
||||||
private flushResolve: (() => void) | undefined;
|
private flushResolve: (() => void) | undefined;
|
||||||
|
|
||||||
public run(): void {
|
public run(backupLevel: BackupLevel): void {
|
||||||
drop(
|
drop(
|
||||||
(async () => {
|
(async () => {
|
||||||
log.info('BackupExportStream: starting...');
|
log.info('BackupExportStream: starting...');
|
||||||
await Data.pauseWriteAccess();
|
await Data.pauseWriteAccess();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.unsafeRun();
|
await this.unsafeRun(backupLevel);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -134,7 +141,7 @@ export class BackupExportStream extends Readable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async unsafeRun(): Promise<void> {
|
private async unsafeRun(backupLevel: BackupLevel): Promise<void> {
|
||||||
this.push(
|
this.push(
|
||||||
Backups.BackupInfo.encodeDelimited({
|
Backups.BackupInfo.encodeDelimited({
|
||||||
version: Long.fromNumber(BACKUP_VERSION),
|
version: Long.fromNumber(BACKUP_VERSION),
|
||||||
|
@ -279,7 +286,12 @@ export class BackupExportStream extends Readable {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const items = await pMap(
|
const items = await pMap(
|
||||||
messages,
|
messages,
|
||||||
message => this.toChatItem(message, { aboutMe, callHistoryByCallId }),
|
message =>
|
||||||
|
this.toChatItem(message, {
|
||||||
|
aboutMe,
|
||||||
|
callHistoryByCallId,
|
||||||
|
backupLevel,
|
||||||
|
}),
|
||||||
{ concurrency: MAX_CONCURRENCY }
|
{ concurrency: MAX_CONCURRENCY }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -308,6 +320,7 @@ export class BackupExportStream extends Readable {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.flush();
|
await this.flush();
|
||||||
|
|
||||||
log.warn('backups: final stats', stats);
|
log.warn('backups: final stats', stats);
|
||||||
|
|
||||||
this.push(null);
|
this.push(null);
|
||||||
|
@ -562,6 +575,7 @@ export class BackupExportStream extends Readable {
|
||||||
options: {
|
options: {
|
||||||
aboutMe: AboutMe;
|
aboutMe: AboutMe;
|
||||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
}
|
}
|
||||||
): Promise<Backups.IChatItem | undefined> {
|
): Promise<Backups.IChatItem | undefined> {
|
||||||
const chatId = this.getRecipientId({ id: message.conversationId });
|
const chatId = this.getRecipientId({ id: message.conversationId });
|
||||||
|
@ -629,6 +643,16 @@ export class BackupExportStream extends Readable {
|
||||||
// TODO (DESKTOP-6964): put incoming/outgoing fields below onto non-bubble messages
|
// TODO (DESKTOP-6964): put incoming/outgoing fields below onto non-bubble messages
|
||||||
result.standardMessage = {
|
result.standardMessage = {
|
||||||
quote: await this.toQuote(message.quote),
|
quote: await this.toQuote(message.quote),
|
||||||
|
attachments: message.attachments
|
||||||
|
? await Promise.all(
|
||||||
|
message.attachments.map(attachment => {
|
||||||
|
return this.processMessageAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel: options.backupLevel,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
text: {
|
text: {
|
||||||
// Note that we store full text on the message model so we have to
|
// Note that we store full text on the message model so we have to
|
||||||
// trim it before serializing.
|
// trim it before serializing.
|
||||||
|
@ -1579,6 +1603,63 @@ export class BackupExportStream extends Readable {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMessageAttachmentFlag(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): Backups.MessageAttachment.Flag {
|
||||||
|
if (isVoiceMessage(attachment)) {
|
||||||
|
return Backups.MessageAttachment.Flag.VOICE_MESSAGE;
|
||||||
|
}
|
||||||
|
if (isGIF([attachment])) {
|
||||||
|
return Backups.MessageAttachment.Flag.GIF;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
attachment.flags &&
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
attachment.flags & SignalService.AttachmentPointer.Flags.BORDERLESS
|
||||||
|
) {
|
||||||
|
return Backups.MessageAttachment.Flag.BORDERLESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Backups.MessageAttachment.Flag.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processMessageAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentType;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
}): Promise<Backups.MessageAttachment> {
|
||||||
|
const filePointer = await this.processAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Backups.MessageAttachment({
|
||||||
|
pointer: filePointer,
|
||||||
|
flag: this.getMessageAttachmentFlag(attachment),
|
||||||
|
wasDownloaded: isDownloaded(attachment), // should always be true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processAttachment({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentType;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
}): Promise<Backups.FilePointer> {
|
||||||
|
const filePointer = await convertAttachmentToFilePointer({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
// TODO (DESKTOP-6983) -- Retrieve & save backup tier media list
|
||||||
|
getBackupTierInfo: () => ({
|
||||||
|
isInBackupTier: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return filePointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkServiceIdEquivalence(
|
function checkServiceIdEquivalence(
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { join } from 'path';
|
||||||
import { createGzip, createGunzip } from 'zlib';
|
import { createGzip, createGunzip } from 'zlib';
|
||||||
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
|
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
|
@ -68,8 +69,11 @@ export class BackupsService {
|
||||||
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
||||||
const filePath = join(window.BasePaths.temp, fileName);
|
const filePath = join(window.BasePaths.temp, fileName);
|
||||||
|
|
||||||
|
const backupLevel = await this.credentials.getBackupLevel();
|
||||||
|
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileSize = await this.exportToDisk(filePath);
|
const fileSize = await this.exportToDisk(filePath, backupLevel);
|
||||||
|
|
||||||
await this.api.upload(filePath, fileSize);
|
await this.api.upload(filePath, fileSize);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -82,19 +86,24 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportBackupData(): Promise<Uint8Array> {
|
public async exportBackupData(
|
||||||
|
backupLevel: BackupLevel = BackupLevel.Messages
|
||||||
|
): Promise<Uint8Array> {
|
||||||
const sink = new PassThrough();
|
const sink = new PassThrough();
|
||||||
|
|
||||||
const chunks = new Array<Uint8Array>();
|
const chunks = new Array<Uint8Array>();
|
||||||
sink.on('data', chunk => chunks.push(chunk));
|
sink.on('data', chunk => chunks.push(chunk));
|
||||||
await this.exportBackup(sink);
|
await this.exportBackup(sink, backupLevel);
|
||||||
|
|
||||||
return Bytes.concatenate(chunks);
|
return Bytes.concatenate(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test harness
|
// Test harness
|
||||||
public async exportToDisk(path: string): Promise<number> {
|
public async exportToDisk(
|
||||||
const size = await this.exportBackup(createWriteStream(path));
|
path: string,
|
||||||
|
backupLevel: BackupLevel = BackupLevel.Messages
|
||||||
|
): Promise<number> {
|
||||||
|
const size = await this.exportBackup(createWriteStream(path), backupLevel);
|
||||||
|
|
||||||
await validateBackup(path, size);
|
await validateBackup(path, size);
|
||||||
|
|
||||||
|
@ -174,7 +183,10 @@ export class BackupsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async exportBackup(sink: Writable): Promise<number> {
|
private async exportBackup(
|
||||||
|
sink: Writable,
|
||||||
|
backupLevel: BackupLevel = BackupLevel.Messages
|
||||||
|
): Promise<number> {
|
||||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||||
|
|
||||||
log.info('exportBackup: starting...');
|
log.info('exportBackup: starting...');
|
||||||
|
@ -184,7 +196,7 @@ export class BackupsService {
|
||||||
const { aesKey, macKey } = getKeyMaterial();
|
const { aesKey, macKey } = getKeyMaterial();
|
||||||
|
|
||||||
const recordStream = new BackupExportStream();
|
const recordStream = new BackupExportStream();
|
||||||
recordStream.run();
|
recordStream.run(backupLevel);
|
||||||
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,28 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import Long from 'long';
|
||||||
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APPLICATION_OCTET_STREAM,
|
APPLICATION_OCTET_STREAM,
|
||||||
stringToMIMEType,
|
stringToMIMEType,
|
||||||
} from '../../../types/MIME';
|
} from '../../../types/MIME';
|
||||||
import type { AttachmentType } from '../../../types/Attachment';
|
import {
|
||||||
import type { Backups } from '../../../protobuf';
|
type AttachmentType,
|
||||||
|
isDownloadableFromTransitTier,
|
||||||
|
isDownloadableFromBackupTier,
|
||||||
|
isDownloadedToLocalFile,
|
||||||
|
type AttachmentDownloadableFromTransitTier,
|
||||||
|
type AttachmentDownloadableFromBackupTier,
|
||||||
|
type DownloadedAttachment,
|
||||||
|
type AttachmentReadyForBackup,
|
||||||
|
} from '../../../types/Attachment';
|
||||||
|
import { Backups } from '../../../protobuf';
|
||||||
import * as Bytes from '../../../Bytes';
|
import * as Bytes from '../../../Bytes';
|
||||||
import { getTimestampFromLong } from '../../../util/timestampLongUtils';
|
import { getTimestampFromLong } from '../../../util/timestampLongUtils';
|
||||||
|
import { getRandomBytes } from '../../../Crypto';
|
||||||
|
import { encryptAttachmentV2 } from '../../../AttachmentCrypto';
|
||||||
|
import { strictAssert } from '../../../util/assert';
|
||||||
|
|
||||||
export function convertFilePointerToAttachment(
|
export function convertFilePointerToAttachment(
|
||||||
filePointer: Backups.FilePointer
|
filePointer: Backups.FilePointer
|
||||||
|
@ -94,3 +109,174 @@ export function convertFilePointerToAttachment(
|
||||||
|
|
||||||
throw new Error('convertFilePointerToAttachment: mising locator');
|
throw new Error('convertFilePointerToAttachment: mising locator');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some attachments saved on desktop do not include the key used to encrypt the file
|
||||||
|
* originally. This means that we need to encrypt the file in-memory now (at
|
||||||
|
* export-creation time) to calculate the digest which will be saved in the backup proto
|
||||||
|
* along with the new keys.
|
||||||
|
*/
|
||||||
|
async function fixupAttachmentForBackup(
|
||||||
|
attachment: DownloadedAttachment
|
||||||
|
): Promise<AttachmentReadyForBackup> {
|
||||||
|
const fixedUpAttachment = { ...attachment };
|
||||||
|
const keyToUse = attachment.key ?? Bytes.toBase64(getRandomBytes(64));
|
||||||
|
let digestToUse = attachment.key ? attachment.digest : undefined;
|
||||||
|
|
||||||
|
if (!digestToUse) {
|
||||||
|
// Delete current locators for the attachment; we can no longer use them and will need
|
||||||
|
// to fully re-encrypt and upload
|
||||||
|
delete fixedUpAttachment.cdnId;
|
||||||
|
delete fixedUpAttachment.cdnKey;
|
||||||
|
delete fixedUpAttachment.cdnNumber;
|
||||||
|
|
||||||
|
// encrypt this file in memory in order to calculate the digest
|
||||||
|
const { digest } = await encryptAttachmentV2({
|
||||||
|
keys: Bytes.fromBase64(keyToUse),
|
||||||
|
plaintextAbsolutePath: window.Signal.Migrations.getAbsoluteAttachmentPath(
|
||||||
|
attachment.path
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
digestToUse = Bytes.toBase64(digest);
|
||||||
|
|
||||||
|
// TODO (DESKTOP-6688): ensure that we update the message/attachment in DB with the
|
||||||
|
// new keys so that we don't try to re-upload it again on the next export
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fixedUpAttachment,
|
||||||
|
key: keyToUse,
|
||||||
|
digest: digestToUse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertAttachmentToFilePointer({
|
||||||
|
attachment,
|
||||||
|
backupLevel,
|
||||||
|
getBackupTierInfo,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentType;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
getBackupTierInfo: (
|
||||||
|
mediaName: string
|
||||||
|
) => { isInBackupTier: true; cdnNumber: number } | { isInBackupTier: false };
|
||||||
|
}): Promise<Backups.FilePointer> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDownloadedToLocalFile(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 new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
backupLocator: getBackupLocator(attachment),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Otherwise, we only return the transit CDN info via AttachmentLocator
|
||||||
|
if (isDownloadableFromTransitTier(attachment)) {
|
||||||
|
return new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
attachmentLocator: getAttachmentLocator(attachment),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupLevel !== BackupLevel.Media) {
|
||||||
|
if (isDownloadableFromTransitTier(attachment)) {
|
||||||
|
return new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
attachmentLocator: getAttachmentLocator(attachment),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
invalidAttachmentLocator: getInvalidAttachmentLocator(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDownloadedToLocalFile(attachment)) {
|
||||||
|
return new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
invalidAttachmentLocator: getInvalidAttachmentLocator(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentForBackup = await fixupAttachmentForBackup(attachment);
|
||||||
|
const mediaName = getMediaNameForAttachment(attachmentForBackup);
|
||||||
|
|
||||||
|
const backupTierInfo = getBackupTierInfo(mediaName);
|
||||||
|
let cdnNumberInBackupTier: number | undefined;
|
||||||
|
if (backupTierInfo.isInBackupTier) {
|
||||||
|
cdnNumberInBackupTier = backupTierInfo.cdnNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
backupLocator: getBackupLocator({
|
||||||
|
...attachmentForBackup,
|
||||||
|
backupLocator: {
|
||||||
|
mediaName,
|
||||||
|
cdnNumber: cdnNumberInBackupTier,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaNameForAttachment(attachment: AttachmentType): string {
|
||||||
|
strictAssert(attachment.digest, 'Digest must be present');
|
||||||
|
return attachment.digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaId is special in that it is encoded in base64url
|
||||||
|
export function getBytesFromMediaId(mediaId: string): Uint8Array {
|
||||||
|
return Bytes.fromBase64url(mediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: AttachmentDownloadableFromBackupTier) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ import {
|
||||||
KEY_SET_LENGTH,
|
KEY_SET_LENGTH,
|
||||||
_generateAttachmentIv,
|
_generateAttachmentIv,
|
||||||
decryptAttachmentV2,
|
decryptAttachmentV2,
|
||||||
encryptAttachmentV2,
|
encryptAttachmentV2ToDisk,
|
||||||
getAesCbcCiphertextLength,
|
getAesCbcCiphertextLength,
|
||||||
splitKeys,
|
splitKeys,
|
||||||
} from '../AttachmentCrypto';
|
} from '../AttachmentCrypto';
|
||||||
|
@ -607,7 +607,7 @@ describe('Crypto', () => {
|
||||||
let ciphertextPath;
|
let ciphertextPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedAttachment = await encryptAttachmentV2({
|
const encryptedAttachment = await encryptAttachmentV2ToDisk({
|
||||||
keys,
|
keys,
|
||||||
plaintextAbsolutePath: FILE_PATH,
|
plaintextAbsolutePath: FILE_PATH,
|
||||||
});
|
});
|
||||||
|
@ -655,7 +655,7 @@ describe('Crypto', () => {
|
||||||
let ciphertextPath;
|
let ciphertextPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedAttachment = await encryptAttachmentV2({
|
const encryptedAttachment = await encryptAttachmentV2ToDisk({
|
||||||
keys,
|
keys,
|
||||||
plaintextAbsolutePath: sourcePath,
|
plaintextAbsolutePath: sourcePath,
|
||||||
});
|
});
|
||||||
|
@ -700,7 +700,7 @@ describe('Crypto', () => {
|
||||||
let ciphertextPath;
|
let ciphertextPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedAttachment = await encryptAttachmentV2({
|
const encryptedAttachment = await encryptAttachmentV2ToDisk({
|
||||||
keys,
|
keys,
|
||||||
plaintextAbsolutePath: FILE_PATH,
|
plaintextAbsolutePath: FILE_PATH,
|
||||||
});
|
});
|
||||||
|
@ -753,7 +753,7 @@ describe('Crypto', () => {
|
||||||
});
|
});
|
||||||
const ciphertextV1 = encryptedAttachmentV1.ciphertext;
|
const ciphertextV1 = encryptedAttachmentV1.ciphertext;
|
||||||
|
|
||||||
const encryptedAttachmentV2 = await encryptAttachmentV2({
|
const encryptedAttachmentV2 = await encryptAttachmentV2ToDisk({
|
||||||
keys,
|
keys,
|
||||||
plaintextAbsolutePath: FILE_PATH,
|
plaintextAbsolutePath: FILE_PATH,
|
||||||
dangerousTestOnlyIv,
|
dangerousTestOnlyIv,
|
||||||
|
@ -788,7 +788,7 @@ describe('Crypto', () => {
|
||||||
let outerCiphertextPath;
|
let outerCiphertextPath;
|
||||||
let innerEncryptedAttachment;
|
let innerEncryptedAttachment;
|
||||||
try {
|
try {
|
||||||
innerEncryptedAttachment = await encryptAttachmentV2({
|
innerEncryptedAttachment = await encryptAttachmentV2ToDisk({
|
||||||
keys: innerKeys,
|
keys: innerKeys,
|
||||||
plaintextAbsolutePath,
|
plaintextAbsolutePath,
|
||||||
});
|
});
|
||||||
|
@ -797,7 +797,7 @@ describe('Crypto', () => {
|
||||||
innerEncryptedAttachment.path
|
innerEncryptedAttachment.path
|
||||||
);
|
);
|
||||||
|
|
||||||
const outerEncryptedAttachment = await encryptAttachmentV2({
|
const outerEncryptedAttachment = await encryptAttachmentV2ToDisk({
|
||||||
keys: outerKeys,
|
keys: outerKeys,
|
||||||
plaintextAbsolutePath: innerCiphertextPath,
|
plaintextAbsolutePath: innerCiphertextPath,
|
||||||
// We (and the server!) don't pad the second layer
|
// We (and the server!) don't pad the second layer
|
||||||
|
|
461
ts/test-electron/backup/filePointer_test.ts
Normal file
461
ts/test-electron/backup/filePointer_test.ts
Normal file
|
@ -0,0 +1,461 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import Long from 'long';
|
||||||
|
import { join } from 'path';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
|
import { Backups } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
convertAttachmentToFilePointer,
|
||||||
|
convertFilePointerToAttachment,
|
||||||
|
} from '../../services/backups/util/filePointers';
|
||||||
|
import { APPLICATION_OCTET_STREAM, IMAGE_PNG } from '../../types/MIME';
|
||||||
|
import * as Bytes from '../../Bytes';
|
||||||
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
|
describe('convertFilePointerToAttachment', () => {
|
||||||
|
it('processes filepointer with attachmentLocator', () => {
|
||||||
|
const result = convertFilePointerToAttachment(
|
||||||
|
new Backups.FilePointer({
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: Bytes.fromString('incrementalMac'),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
attachmentLocator: new Backups.FilePointer.AttachmentLocator({
|
||||||
|
size: 128,
|
||||||
|
cdnKey: 'cdnKey',
|
||||||
|
cdnNumber: 2,
|
||||||
|
key: Bytes.fromString('key'),
|
||||||
|
digest: Bytes.fromString('digest'),
|
||||||
|
uploadTimestamp: Long.fromNumber(1970),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
size: 128,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
cdnKey: 'cdnKey',
|
||||||
|
cdnNumber: 2,
|
||||||
|
key: Bytes.toBase64(Bytes.fromString('key')),
|
||||||
|
digest: Bytes.toBase64(Bytes.fromString('digest')),
|
||||||
|
uploadTimestamp: 1970,
|
||||||
|
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes filepointer with backupLocator and missing fields', () => {
|
||||||
|
const result = convertFilePointerToAttachment(
|
||||||
|
new Backups.FilePointer({
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: Bytes.fromString('incrementalMac'),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
backupLocator: new Backups.FilePointer.BackupLocator({
|
||||||
|
mediaName: 'mediaName',
|
||||||
|
cdnNumber: 3,
|
||||||
|
size: 128,
|
||||||
|
key: Bytes.fromString('key'),
|
||||||
|
digest: Bytes.fromString('digest'),
|
||||||
|
transitCdnKey: 'transitCdnKey',
|
||||||
|
transitCdnNumber: 2,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
size: 128,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
cdnKey: 'transitCdnKey',
|
||||||
|
cdnNumber: 2,
|
||||||
|
key: Bytes.toBase64(Bytes.fromString('key')),
|
||||||
|
digest: Bytes.toBase64(Bytes.fromString('digest')),
|
||||||
|
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
backupLocator: {
|
||||||
|
mediaName: 'mediaName',
|
||||||
|
cdnNumber: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes filepointer with invalidAttachmentLocator', () => {
|
||||||
|
const result = convertFilePointerToAttachment(
|
||||||
|
new Backups.FilePointer({
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: Bytes.fromString('incrementalMac'),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
invalidAttachmentLocator:
|
||||||
|
new Backups.FilePointer.InvalidAttachmentLocator(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
size: 0,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts missing / null fields and adds defaults to contentType and size', () => {
|
||||||
|
const result = convertFilePointerToAttachment(
|
||||||
|
new Backups.FilePointer({
|
||||||
|
backupLocator: new Backups.FilePointer.BackupLocator(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
contentType: APPLICATION_OCTET_STREAM,
|
||||||
|
size: 0,
|
||||||
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
blurHash: undefined,
|
||||||
|
fileName: undefined,
|
||||||
|
caption: undefined,
|
||||||
|
cdnKey: undefined,
|
||||||
|
cdnNumber: undefined,
|
||||||
|
key: undefined,
|
||||||
|
digest: undefined,
|
||||||
|
incrementalMac: undefined,
|
||||||
|
incrementalMacChunkSize: undefined,
|
||||||
|
backupLocator: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function composeAttachment(
|
||||||
|
overrides: Partial<AttachmentType> = {}
|
||||||
|
): AttachmentType {
|
||||||
|
return {
|
||||||
|
size: 100,
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
cdnKey: 'cdnKey',
|
||||||
|
cdnNumber: 2,
|
||||||
|
path: 'path/to/file.png',
|
||||||
|
key: 'key',
|
||||||
|
digest: 'digest',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: 'incrementalMac',
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
uploadTimestamp: 1234,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFilePointer = new Backups.FilePointer({
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
blurHash: 'blurhash',
|
||||||
|
fileName: 'filename',
|
||||||
|
caption: 'caption',
|
||||||
|
incrementalMac: Bytes.fromBase64('incrementalMac'),
|
||||||
|
incrementalMacChunkSize: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultAttachmentLocator = new Backups.FilePointer.AttachmentLocator({
|
||||||
|
cdnKey: 'cdnKey',
|
||||||
|
cdnNumber: 2,
|
||||||
|
key: Bytes.fromBase64('key'),
|
||||||
|
digest: Bytes.fromBase64('digest'),
|
||||||
|
size: 100,
|
||||||
|
uploadTimestamp: Long.fromNumber(1234),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultMediaName = 'digest';
|
||||||
|
const defaultBackupLocator = new Backups.FilePointer.BackupLocator({
|
||||||
|
mediaName: defaultMediaName,
|
||||||
|
cdnNumber: null,
|
||||||
|
key: Bytes.fromBase64('key'),
|
||||||
|
digest: Bytes.fromBase64('digest'),
|
||||||
|
size: 100,
|
||||||
|
transitCdnKey: 'cdnKey',
|
||||||
|
transitCdnNumber: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePointerWithAttachmentLocator = new Backups.FilePointer({
|
||||||
|
...defaultFilePointer,
|
||||||
|
attachmentLocator: defaultAttachmentLocator,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePointerWithBackupLocator = new Backups.FilePointer({
|
||||||
|
...defaultFilePointer,
|
||||||
|
backupLocator: defaultBackupLocator,
|
||||||
|
});
|
||||||
|
const filePointerWithInvalidLocator = new Backups.FilePointer({
|
||||||
|
...defaultFilePointer,
|
||||||
|
invalidAttachmentLocator: new Backups.FilePointer.InvalidAttachmentLocator(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function testAttachmentToFilePointer(
|
||||||
|
attachment: AttachmentType,
|
||||||
|
filePointer: Backups.FilePointer,
|
||||||
|
options?: { backupLevel?: BackupLevel; backupCdnNumber?: number }
|
||||||
|
) {
|
||||||
|
async function _doTest(withBackupLevel: BackupLevel) {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
await convertAttachmentToFilePointer({
|
||||||
|
attachment,
|
||||||
|
backupLevel: withBackupLevel,
|
||||||
|
getBackupTierInfo: _mediaName => {
|
||||||
|
if (options?.backupCdnNumber != null) {
|
||||||
|
return { isInBackupTier: true, cdnNumber: options.backupCdnNumber };
|
||||||
|
}
|
||||||
|
return { isInBackupTier: false };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
filePointer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.backupLevel) {
|
||||||
|
await _doTest(BackupLevel.Messages);
|
||||||
|
await _doTest(BackupLevel.Media);
|
||||||
|
} else {
|
||||||
|
await _doTest(options.backupLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('convertAttachmentToFilePointer', () => {
|
||||||
|
describe('not downloaded locally', () => {
|
||||||
|
const undownloadedAttachment = composeAttachment({ path: undefined });
|
||||||
|
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...undownloadedAttachment,
|
||||||
|
key: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator
|
||||||
|
);
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...undownloadedAttachment,
|
||||||
|
digest: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator
|
||||||
|
);
|
||||||
|
});
|
||||||
|
describe('attachment does not have attachment.backupLocator', () => {
|
||||||
|
it('returns attachmentLocator, regardless of backupLevel or backup tier status', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
undownloadedAttachment,
|
||||||
|
filePointerWithAttachmentLocator,
|
||||||
|
{ backupCdnNumber: 3 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalidAttachmentLocator if missing critical locator info', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...undownloadedAttachment,
|
||||||
|
cdnKey: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator
|
||||||
|
);
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...undownloadedAttachment,
|
||||||
|
cdnNumber: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('attachment has attachment.backupLocator', () => {
|
||||||
|
const undownloadedAttachmentWithBackupLocator = {
|
||||||
|
...undownloadedAttachment,
|
||||||
|
backupLocator: { mediaName: defaultMediaName },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns backupLocator if backupLevel is Media', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
undownloadedAttachmentWithBackupLocator,
|
||||||
|
filePointerWithBackupLocator,
|
||||||
|
{ backupLevel: BackupLevel.Media }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns backupLocator even if missing transit CDN info', async () => {
|
||||||
|
// Even if missing transit CDNKey
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{ ...undownloadedAttachmentWithBackupLocator, cdnKey: undefined },
|
||||||
|
new Backups.FilePointer({
|
||||||
|
...filePointerWithBackupLocator,
|
||||||
|
backupLocator: new Backups.FilePointer.BackupLocator({
|
||||||
|
...defaultBackupLocator,
|
||||||
|
transitCdnKey: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ backupLevel: BackupLevel.Media }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns attachmentLocator if backupLevel is Messages', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
undownloadedAttachmentWithBackupLocator,
|
||||||
|
filePointerWithAttachmentLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('downloaded locally', () => {
|
||||||
|
const downloadedAttachment = composeAttachment();
|
||||||
|
describe('BackupLevel.Messages', () => {
|
||||||
|
it('returns attachmentLocator', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
downloadedAttachment,
|
||||||
|
filePointerWithAttachmentLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('returns invalidAttachmentLocator if missing critical locator info', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...downloadedAttachment,
|
||||||
|
cdnKey: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...downloadedAttachment,
|
||||||
|
cdnNumber: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...downloadedAttachment,
|
||||||
|
key: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...downloadedAttachment,
|
||||||
|
digest: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator,
|
||||||
|
{ backupLevel: BackupLevel.Messages }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('BackupLevel.Media', () => {
|
||||||
|
describe('if missing critical decryption info', () => {
|
||||||
|
const FILE_PATH = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
|
||||||
|
|
||||||
|
let sandbox: sinon.SinonSandbox;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sandbox = sinon.createSandbox();
|
||||||
|
sandbox
|
||||||
|
.stub(window.Signal.Migrations, 'getAbsoluteAttachmentPath')
|
||||||
|
.callsFake(relPath => {
|
||||||
|
if (relPath === downloadedAttachment.path) {
|
||||||
|
return FILE_PATH;
|
||||||
|
}
|
||||||
|
return relPath;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sandbox.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates new key & digest and removes existing CDN info', async () => {
|
||||||
|
const result = await convertAttachmentToFilePointer({
|
||||||
|
attachment: {
|
||||||
|
...downloadedAttachment,
|
||||||
|
key: undefined,
|
||||||
|
},
|
||||||
|
backupLevel: BackupLevel.Media,
|
||||||
|
getBackupTierInfo: () => ({ isInBackupTier: false }),
|
||||||
|
});
|
||||||
|
const newKey = result.backupLocator?.key;
|
||||||
|
const newDigest = result.backupLocator?.digest;
|
||||||
|
|
||||||
|
strictAssert(newDigest, 'must create new digest');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
result,
|
||||||
|
new Backups.FilePointer({
|
||||||
|
...filePointerWithBackupLocator,
|
||||||
|
backupLocator: new Backups.FilePointer.BackupLocator({
|
||||||
|
...defaultBackupLocator,
|
||||||
|
key: newKey,
|
||||||
|
digest: newDigest,
|
||||||
|
mediaName: Bytes.toBase64(newDigest),
|
||||||
|
transitCdnKey: undefined,
|
||||||
|
transitCdnNumber: undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns BackupLocator, with cdnNumber if in backup tier already', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
downloadedAttachment,
|
||||||
|
new Backups.FilePointer({
|
||||||
|
...filePointerWithBackupLocator,
|
||||||
|
backupLocator: new Backups.FilePointer.BackupLocator({
|
||||||
|
...defaultBackupLocator,
|
||||||
|
cdnNumber: 12,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ backupLevel: BackupLevel.Media, backupCdnNumber: 12 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns BackupLocator, with empty cdnNumber if not in backup tier', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
downloadedAttachment,
|
||||||
|
filePointerWithBackupLocator,
|
||||||
|
{ backupLevel: BackupLevel.Media }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,149 +0,0 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
import { assert } from 'chai';
|
|
||||||
import Long from 'long';
|
|
||||||
import { Backups } from '../../protobuf';
|
|
||||||
import { convertFilePointerToAttachment } from '../../services/backups/util/filePointers';
|
|
||||||
import { APPLICATION_OCTET_STREAM, IMAGE_PNG } from '../../types/MIME';
|
|
||||||
import * as Bytes from '../../Bytes';
|
|
||||||
|
|
||||||
describe('convertFilePointerToAttachment', () => {
|
|
||||||
it('processes filepointer with attachmentLocator', () => {
|
|
||||||
const result = convertFilePointerToAttachment(
|
|
||||||
new Backups.FilePointer({
|
|
||||||
contentType: 'image/png',
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
incrementalMac: Bytes.fromString('incrementalMac'),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
attachmentLocator: new Backups.FilePointer.AttachmentLocator({
|
|
||||||
size: 128,
|
|
||||||
cdnKey: 'cdnKey',
|
|
||||||
cdnNumber: 2,
|
|
||||||
key: Bytes.fromString('key'),
|
|
||||||
digest: Bytes.fromString('digest'),
|
|
||||||
uploadTimestamp: Long.fromNumber(1970),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(result, {
|
|
||||||
contentType: IMAGE_PNG,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
size: 128,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
cdnKey: 'cdnKey',
|
|
||||||
cdnNumber: 2,
|
|
||||||
key: Bytes.toBase64(Bytes.fromString('key')),
|
|
||||||
digest: Bytes.toBase64(Bytes.fromString('digest')),
|
|
||||||
uploadTimestamp: 1970,
|
|
||||||
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('processes filepointer with backupLocator and missing fields', () => {
|
|
||||||
const result = convertFilePointerToAttachment(
|
|
||||||
new Backups.FilePointer({
|
|
||||||
contentType: 'image/png',
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
incrementalMac: Bytes.fromString('incrementalMac'),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
backupLocator: new Backups.FilePointer.BackupLocator({
|
|
||||||
mediaName: 'mediaName',
|
|
||||||
cdnNumber: 3,
|
|
||||||
size: 128,
|
|
||||||
key: Bytes.fromString('key'),
|
|
||||||
digest: Bytes.fromString('digest'),
|
|
||||||
transitCdnKey: 'transitCdnKey',
|
|
||||||
transitCdnNumber: 2,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(result, {
|
|
||||||
contentType: IMAGE_PNG,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
size: 128,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
cdnKey: 'transitCdnKey',
|
|
||||||
cdnNumber: 2,
|
|
||||||
key: Bytes.toBase64(Bytes.fromString('key')),
|
|
||||||
digest: Bytes.toBase64(Bytes.fromString('digest')),
|
|
||||||
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
backupLocator: {
|
|
||||||
mediaName: 'mediaName',
|
|
||||||
cdnNumber: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('processes filepointer with invalidAttachmentLocator', () => {
|
|
||||||
const result = convertFilePointerToAttachment(
|
|
||||||
new Backups.FilePointer({
|
|
||||||
contentType: 'image/png',
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
incrementalMac: Bytes.fromString('incrementalMac'),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
invalidAttachmentLocator:
|
|
||||||
new Backups.FilePointer.InvalidAttachmentLocator(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(result, {
|
|
||||||
contentType: IMAGE_PNG,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
blurHash: 'blurhash',
|
|
||||||
fileName: 'filename',
|
|
||||||
caption: 'caption',
|
|
||||||
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
|
|
||||||
incrementalMacChunkSize: 1000,
|
|
||||||
size: 0,
|
|
||||||
error: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts missing / null fields and adds defaults to contentType and size', () => {
|
|
||||||
const result = convertFilePointerToAttachment(
|
|
||||||
new Backups.FilePointer({
|
|
||||||
backupLocator: new Backups.FilePointer.BackupLocator(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(result, {
|
|
||||||
contentType: APPLICATION_OCTET_STREAM,
|
|
||||||
size: 0,
|
|
||||||
width: undefined,
|
|
||||||
height: undefined,
|
|
||||||
blurHash: undefined,
|
|
||||||
fileName: undefined,
|
|
||||||
caption: undefined,
|
|
||||||
cdnKey: undefined,
|
|
||||||
cdnNumber: undefined,
|
|
||||||
key: undefined,
|
|
||||||
digest: undefined,
|
|
||||||
incrementalMac: undefined,
|
|
||||||
incrementalMacChunkSize: undefined,
|
|
||||||
backupLocator: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -21,7 +21,7 @@ import {
|
||||||
isImageTypeSupported,
|
isImageTypeSupported,
|
||||||
isVideoTypeSupported,
|
isVideoTypeSupported,
|
||||||
} from '../util/GoogleChrome';
|
} from '../util/GoogleChrome';
|
||||||
import type { LocalizerType } from './Util';
|
import type { LocalizerType, WithRequiredProperties } from './Util';
|
||||||
import { ThemeType } from './Util';
|
import { ThemeType } from './Util';
|
||||||
import * as GoogleChrome from '../util/GoogleChrome';
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
@ -994,3 +994,66 @@ export function getAttachmentSignature(attachment: AttachmentType): string {
|
||||||
strictAssert(attachment.digest, 'attachment missing digest');
|
strictAssert(attachment.digest, 'attachment missing digest');
|
||||||
return attachment.digest;
|
return attachment.digest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequiredPropertiesForDecryption = 'key' | 'digest';
|
||||||
|
|
||||||
|
type DecryptableAttachment = WithRequiredProperties<
|
||||||
|
AttachmentType,
|
||||||
|
RequiredPropertiesForDecryption
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AttachmentDownloadableFromTransitTier = WithRequiredProperties<
|
||||||
|
DecryptableAttachment,
|
||||||
|
'cdnKey' | 'cdnNumber'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AttachmentDownloadableFromBackupTier = WithRequiredProperties<
|
||||||
|
DecryptableAttachment,
|
||||||
|
'backupLocator'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DownloadedAttachment = WithRequiredProperties<
|
||||||
|
AttachmentType,
|
||||||
|
'path'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AttachmentReadyForBackup = WithRequiredProperties<
|
||||||
|
DownloadedAttachment,
|
||||||
|
RequiredPropertiesForDecryption
|
||||||
|
>;
|
||||||
|
|
||||||
|
function isDecryptable(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): attachment is DecryptableAttachment {
|
||||||
|
return Boolean(attachment.key) && Boolean(attachment.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDownloadableFromTransitTier(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): attachment is AttachmentDownloadableFromTransitTier {
|
||||||
|
if (!isDecryptable(attachment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (attachment.cdnKey && attachment.cdnNumber) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDownloadableFromBackupTier(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): attachment is AttachmentDownloadableFromBackupTier {
|
||||||
|
if (!attachment.key || !attachment.digest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (attachment.backupLocator?.mediaName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDownloadedToLocalFile(
|
||||||
|
attachment: AttachmentType
|
||||||
|
): attachment is DownloadedAttachment {
|
||||||
|
return Boolean(attachment.path);
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue