Uint8Array migration

This commit is contained in:
Fedor Indutny 2021-09-23 17:49:05 -07:00 committed by GitHub
parent daf75190b8
commit 4ef0bf96cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
137 changed files with 2202 additions and 3170 deletions

View file

@ -6,12 +6,12 @@ import moment from 'moment';
import {
isNumber,
padStart,
isArrayBuffer,
isTypedArray,
isFunction,
isUndefined,
omit,
} from 'lodash';
import { arrayBufferToBlob, blobToArrayBuffer } from 'blob-util';
import { blobToArrayBuffer } from 'blob-util';
import { LoggerType } from './Logging';
import * as MIME from './MIME';
@ -63,14 +63,14 @@ export type AttachmentType = {
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
data?: ArrayBuffer;
data?: Uint8Array;
/** Legacy field. Used only for downloading old attachments */
id?: number;
};
export type DownloadedAttachmentType = AttachmentType & {
data: ArrayBuffer;
data: Uint8Array;
};
export type BaseAttachmentDraftType = {
@ -85,9 +85,9 @@ export type BaseAttachmentDraftType = {
export type InMemoryAttachmentDraftType =
| ({
data?: ArrayBuffer;
data?: Uint8Array;
pending: false;
screenshotData?: ArrayBuffer;
screenshotData?: Uint8Array;
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
@ -138,7 +138,7 @@ export async function migrateDataToFileSystem(
{
writeNewAttachmentData,
}: {
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
}
): Promise<AttachmentType> {
if (!isFunction(writeNewAttachmentData)) {
@ -152,9 +152,9 @@ export async function migrateDataToFileSystem(
return attachment;
}
if (!isArrayBuffer(data)) {
if (!isTypedArray(data)) {
throw new TypeError(
'Expected `attachment.data` to be an array buffer;' +
'Expected `attachment.data` to be a typed array;' +
` got: ${typeof attachment.data}`
);
}
@ -169,19 +169,19 @@ export async function migrateDataToFileSystem(
// {
// id: string
// contentType: MIMEType
// data: ArrayBuffer
// digest: ArrayBuffer
// data: Uint8Array
// digest: Uint8Array
// fileName?: string
// flags: null
// key: ArrayBuffer
// key: Uint8Array
// size: integer
// thumbnail: ArrayBuffer
// thumbnail: Uint8Array
// }
// // Outgoing message attachment fields
// {
// contentType: MIMEType
// data: ArrayBuffer
// data: Uint8Array
// fileName: string
// size: integer
// }
@ -232,10 +232,9 @@ export async function autoOrientJPEG(
return attachment;
}
const dataBlob = await arrayBufferToBlob(
attachment.data,
attachment.contentType
);
const dataBlob = new Blob([attachment.data], {
type: attachment.contentType,
});
const { blob: xcodedDataBlob } = await scaleImageToLevel(
dataBlob,
attachment.contentType,
@ -243,7 +242,7 @@ export async function autoOrientJPEG(
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
@ -251,7 +250,7 @@ export async function autoOrientJPEG(
const xcodedAttachment = {
// `digest` is no longer valid for auto-oriented image data, so we discard it:
...omit(attachment, 'digest'),
data: xcodedDataArrayBuffer,
data: new Uint8Array(xcodedDataArrayBuffer),
size: xcodedDataArrayBuffer.byteLength,
};
@ -335,14 +334,11 @@ export function removeSchemaVersion({
}
export function hasData(attachment: AttachmentType): boolean {
return (
attachment.data instanceof ArrayBuffer ||
ArrayBuffer.isView(attachment.data)
);
return attachment.data instanceof Uint8Array;
}
export function loadData(
readAttachmentData: (path: string) => Promise<ArrayBuffer>
readAttachmentData: (path: string) => Promise<Uint8Array>
): (attachment?: AttachmentType) => Promise<AttachmentType> {
if (!is.function_(readAttachmentData)) {
throw new TypeError("'readAttachmentData' must be a function");
@ -400,9 +396,12 @@ const THUMBNAIL_CONTENT_TYPE = MIME.IMAGE_PNG;
export async function captureDimensionsAndScreenshot(
attachment: AttachmentType,
params: {
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
getAbsoluteAttachmentPath: (path: string) => Promise<string>;
makeObjectUrl: (data: ArrayBuffer, contentType: MIME.MIMEType) => string;
makeObjectUrl: (
data: Uint8Array | ArrayBuffer,
contentType: MIME.MIMEType
) => string;
revokeObjectUrl: (path: string) => void;
getImageDimensions: (params: {
objectUrl: string;
@ -464,7 +463,9 @@ export async function captureDimensionsAndScreenshot(
})
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
return {
...attachment,
width,
@ -503,7 +504,9 @@ export async function captureDimensionsAndScreenshot(
objectUrl: screenshotObjectUrl,
logger,
});
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
const screenshotPath = await writeNewAttachmentData(
new Uint8Array(screenshotBuffer)
);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail({
@ -514,7 +517,9 @@ export async function captureDimensionsAndScreenshot(
})
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
return {
...attachment,
@ -876,14 +881,14 @@ export const save = async ({
}: {
attachment: AttachmentType;
index?: number;
readAttachmentData: (relativePath: string) => Promise<ArrayBuffer>;
readAttachmentData: (relativePath: string) => Promise<Uint8Array>;
saveAttachmentToDisk: (options: {
data: ArrayBuffer;
data: Uint8Array;
name: string;
}) => Promise<{ name: string; fullPath: string }>;
}) => Promise<{ name: string; fullPath: string } | null>;
timestamp?: number;
}): Promise<string | null> => {
let data: ArrayBuffer;
let data: Uint8Array;
if (attachment.path) {
data = await readAttachmentData(attachment.path);
} else if (attachment.data) {

View file

@ -42,7 +42,7 @@ export type AvatarIconType = GroupAvatarIconType | PersonalAvatarIconType;
export type AvatarDataType = {
id: number | string;
buffer?: ArrayBuffer;
buffer?: Uint8Array;
color?: AvatarColorType;
icon?: AvatarIconType;
imagePath?: string;

89
ts/types/Conversation.ts Normal file
View file

@ -0,0 +1,89 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { computeHash } from '../Crypto';
import { ConversationAttributesType } from '../model-types.d';
export type BuildAvatarUpdaterOptions = Readonly<{
deleteAttachmentData: (path: string) => Promise<void>;
doesAttachmentExist: (path: string) => Promise<boolean>;
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
}>;
function buildAvatarUpdater({ field }: { field: 'avatar' | 'profileAvatar' }) {
return async (
conversation: Readonly<ConversationAttributesType>,
data: Uint8Array,
{
deleteAttachmentData,
doesAttachmentExist,
writeNewAttachmentData,
}: BuildAvatarUpdaterOptions
): Promise<ConversationAttributesType> => {
if (!conversation) {
return conversation;
}
const avatar = conversation[field];
const newHash = computeHash(data);
if (!avatar || !avatar.hash) {
return {
...conversation,
[field]: {
hash: newHash,
path: await writeNewAttachmentData(data),
},
};
}
const { hash, path } = avatar;
const exists = await doesAttachmentExist(path);
if (!exists) {
window.SignalWindow.log.warn(
`Conversation.buildAvatarUpdater: attachment ${path} did not exist`
);
}
if (exists && hash === newHash) {
return conversation;
}
await deleteAttachmentData(path);
return {
...conversation,
[field]: {
hash: newHash,
path: await writeNewAttachmentData(data),
},
};
};
}
export const maybeUpdateAvatar = buildAvatarUpdater({ field: 'avatar' });
export const maybeUpdateProfileAvatar = buildAvatarUpdater({
field: 'profileAvatar',
});
export async function deleteExternalFiles(
conversation: ConversationAttributesType,
{
deleteAttachmentData,
}: Pick<BuildAvatarUpdaterOptions, 'deleteAttachmentData'>
): Promise<void> {
if (!conversation) {
return;
}
const { avatar, profileAvatar } = conversation;
if (avatar && avatar.path) {
await deleteAttachmentData(avatar.path);
}
if (profileAvatar && profileAvatar.path) {
await deleteAttachmentData(profileAvatar.path);
}
}

13
ts/types/Crypto.ts Normal file
View file

@ -0,0 +1,13 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum HashType {
size256 = 'sha256',
size512 = 'sha512',
}
export enum CipherType {
AES256CBC = 'aes-256-cbc',
AES256CTR = 'aes-256-ctr',
AES256GCM = 'aes-256-gcm',
}

View file

@ -152,7 +152,7 @@ export function parseAndWriteAvatar(
message: MessageAttributesType;
regionCode: string;
logger: Pick<LoggerType, 'error'>;
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
}
): Promise<EmbeddedContactType> => {
const { message, regionCode, logger } = context;

View file

@ -11,7 +11,7 @@ import { replaceEmojiWithSpaces } from '../util/emoji';
import { AttachmentType } from './Attachment';
export type LinkPreviewImage = AttachmentType & {
data: ArrayBuffer;
data: Uint8Array;
};
export type LinkPreviewResult = {

View file

@ -0,0 +1,8 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
export const isValid = (value: unknown): boolean => {
return Boolean(isNumber(value) && value >= 0);
};

View file

@ -9,7 +9,8 @@ import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull';
import { makeLookup } from '../util/makeLookup';
import { maybeParseUrl } from '../util/url';
import { base64ToArrayBuffer, deriveStickerPackKey } from '../Crypto';
import * as Bytes from '../Bytes';
import { deriveStickerPackKey, decryptAttachment } from '../Crypto';
import type {
StickerType,
StickerPackType,
@ -41,9 +42,6 @@ export type DownloadMap = Record<
}
>;
// TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array;
export const BLESSED_PACKS: Record<string, BlessedType> = {
'9acc9e8aba563d26a4994e69263e3b25': {
key: 'Wm3/OUjCjvubeq+T7MN1xp/DFueAd+0mhnoU0QoPahI=',
@ -287,16 +285,10 @@ function getReduxStickerActions() {
return actions.stickers;
}
async function decryptSticker(
packKey: string,
ciphertext: ArrayBuffer
): Promise<ArrayBuffer> {
const binaryKey = base64ToArrayBuffer(packKey);
const derivedKey = await deriveStickerPackKey(binaryKey);
const plaintext = await window.textsecure.crypto.decryptAttachment(
ciphertext,
derivedKey
);
function decryptSticker(packKey: string, ciphertext: Uint8Array): Uint8Array {
const binaryKey = Bytes.fromBase64(packKey);
const derivedKey = deriveStickerPackKey(binaryKey);
const plaintext = decryptAttachment(ciphertext, derivedKey);
return plaintext;
}
@ -311,7 +303,7 @@ async function downloadSticker(
strictAssert(id !== undefined && id !== null, "Sticker id can't be null");
const ciphertext = await window.textsecure.messaging.getSticker(packId, id);
const plaintext = await decryptSticker(packKey, ciphertext);
const plaintext = decryptSticker(packKey, ciphertext);
const sticker = ephemeral
? await window.Signal.Migrations.processNewEphemeralSticker(plaintext)
@ -413,8 +405,8 @@ export async function downloadEphemeralPack(
const ciphertext = await window.textsecure.messaging.getStickerPackManifest(
packId
);
const plaintext = await decryptSticker(packKey, ciphertext);
const proto = Proto.StickerPack.decode(new FIXMEU8(plaintext));
const plaintext = decryptSticker(packKey, ciphertext);
const proto = Proto.StickerPack.decode(plaintext);
const firstStickerProto = proto.stickers ? proto.stickers[0] : null;
const stickerCount = proto.stickers.length;
@ -594,8 +586,8 @@ async function doDownloadStickerPack(
const ciphertext = await window.textsecure.messaging.getStickerPackManifest(
packId
);
const plaintext = await decryptSticker(packKey, ciphertext);
const proto = Proto.StickerPack.decode(new FIXMEU8(plaintext));
const plaintext = decryptSticker(packKey, ciphertext);
const proto = Proto.StickerPack.decode(plaintext);
const firstStickerProto = proto.stickers ? proto.stickers[0] : undefined;
const stickerCount = proto.stickers.length;

View file

@ -21,7 +21,7 @@ import type {
export type SerializedCertificateType = {
expires: number;
serialized: ArrayBuffer;
serialized: Uint8Array;
};
export type ZoomFactorType = 0.75 | 1 | 1.25 | 1.5 | 2 | number;
@ -70,7 +70,7 @@ export type StorageAccessType = {
maxPreKeyId: number;
number_id: string;
password: string;
profileKey: ArrayBuffer;
profileKey: Uint8Array;
regionCode: string;
registrationIdMap: Record<string, number>;
remoteBuildExpiration: number;

View file

@ -0,0 +1,207 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import loadImage from 'blueimp-load-image';
import { blobToArrayBuffer } from 'blob-util';
import { toLogFormat } from './errors';
import { MIMEType, IMAGE_PNG } from './MIME';
import { LoggerType } from './Logging';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { strictAssert } from '../util/assert';
import { canvasToBlob } from '../util/canvasToBlob';
export { blobToArrayBuffer };
export type GetImageDimensionsOptionsType = Readonly<{
objectUrl: string;
logger: Pick<LoggerType, 'error'>;
}>;
export function getImageDimensions({
objectUrl,
logger,
}: GetImageDimensionsOptionsType): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
resolve({
height: image.naturalHeight,
width: image.naturalWidth,
});
});
image.addEventListener('error', error => {
logger.error('getImageDimensions error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
}
export type MakeImageThumbnailOptionsType = Readonly<{
size: number;
objectUrl: string;
contentType?: MIMEType;
logger: Pick<LoggerType, 'error'>;
}>;
export function makeImageThumbnail({
size,
objectUrl,
contentType = IMAGE_PNG,
logger,
}: MakeImageThumbnailOptionsType): Promise<Blob> {
return new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', async () => {
// using components/blueimp-load-image
// first, make the correct size
let canvas = loadImage.scale(image, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
strictAssert(
canvas instanceof HTMLCanvasElement,
'loadImage must produce canvas'
);
try {
const blob = await canvasToBlob(canvas, contentType);
resolve(blob);
} catch (err) {
reject(err);
}
});
image.addEventListener('error', error => {
logger.error('makeImageThumbnail error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
}
export type MakeVideoScreenshotOptionsType = Readonly<{
objectUrl: string;
contentType?: MIMEType;
logger: Pick<LoggerType, 'error'>;
}>;
export function makeVideoScreenshot({
objectUrl,
contentType = IMAGE_PNG,
logger,
}: MakeVideoScreenshotOptionsType): Promise<Blob> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
function seek() {
video.currentTime = 1.0;
}
async function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
strictAssert(context, 'Failed to get canvas context');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
video.addEventListener('loadeddata', seek);
video.removeEventListener('seeked', capture);
try {
const image = canvasToBlob(canvas, contentType);
resolve(image);
} catch (err) {
reject(err);
}
}
video.addEventListener('loadeddata', seek);
video.addEventListener('seeked', capture);
video.addEventListener('error', error => {
logger.error('makeVideoScreenshot error', toLogFormat(error));
reject(error);
});
video.src = objectUrl;
});
}
export type MakeVideoThumbnailOptionsType = Readonly<{
size: number;
videoObjectUrl: string;
logger: Pick<LoggerType, 'error'>;
contentType: MIMEType;
}>;
export async function makeVideoThumbnail({
size,
videoObjectUrl,
logger,
contentType,
}: MakeVideoThumbnailOptionsType): Promise<Blob> {
let screenshotObjectUrl: string | undefined;
try {
const blob = await makeVideoScreenshot({
objectUrl: videoObjectUrl,
contentType,
logger,
});
const data = await blobToArrayBuffer(blob);
screenshotObjectUrl = arrayBufferToObjectURL({
data,
type: contentType,
});
// We need to wait for this, otherwise the finally below will run first
const resultBlob = await makeImageThumbnail({
size,
objectUrl: screenshotObjectUrl,
contentType,
logger,
});
return resultBlob;
} finally {
if (screenshotObjectUrl !== undefined) {
revokeObjectUrl(screenshotObjectUrl);
}
}
}
export function makeObjectUrl(
data: Uint8Array | ArrayBuffer,
contentType: MIMEType
): string {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
}
export function revokeObjectUrl(objectUrl: string): void {
URL.revokeObjectURL(objectUrl);
}

View file

@ -1,6 +1,8 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
export function toLogFormat(error: unknown): string {
if (error instanceof Error && error.stack) {
return error.stack;
@ -10,3 +12,5 @@ export function toLogFormat(error: unknown): string {
}
export class CapabilityError extends Error {}
export class ProfileDecryptError extends Error {}