diff --git a/lib/types/media-types.js b/lib/types/media-types.js index b51a52116..af037d6cc 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,560 +1,589 @@ // @flow import type { Shape } from './core.js'; import { type Platform } from './device-types.js'; export type Dimensions = $ReadOnly<{ +height: number, +width: number, }>; export type MediaType = 'photo' | 'video'; export type Image = { +id: string, +uri: string, +type: 'photo', +dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type EncryptedImage = { +id: string, // a media URI for keyserver uploads / blob holder for Blob service uploads +holder: string, +encryptionKey: string, +type: 'encrypted_photo', +dimensions: Dimensions, }; export type Video = { +id: string, +uri: string, +type: 'video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailURI: string, // stored on native only during creation in case retry needed after state lost +localMediaSelection?: NativeMediaSelection, }; export type EncryptedVideo = { +id: string, // a media URI for keyserver uploads / blob holder for Blob service uploads +holder: string, +encryptionKey: string, +type: 'encrypted_video', +dimensions: Dimensions, +loop?: boolean, +thumbnailID: string, +thumbnailHolder: string, +thumbnailEncryptionKey: string, }; export type Media = Image | Video | EncryptedImage | EncryptedVideo; export type AvatarMediaInfo = { +type: 'photo', +uri: string, }; export type ClientDBMediaInfo = { +id: string, +uri: string, +type: 'photo' | 'video', +extras: string, }; export type Corners = Shape<{ +topLeft: boolean, +topRight: boolean, +bottomLeft: boolean, +bottomRight: boolean, }>; export type MediaInfo = | { ...Image, +index: number, } | { ...Video, +index: number, } | { ...EncryptedImage, +index: number, } | { ...EncryptedVideo, +index: number, }; export type UploadMultimediaResult = { +id: string, +uri: string, +dimensions: Dimensions, +mediaType: MediaType, +loop: boolean, }; export type UpdateMultimediaMessageMediaPayload = { +messageID: string, +currentMediaID: string, +mediaUpdate: Shape, }; export type UploadDeletionRequest = { +id: string, }; export type FFmpegStatistics = { // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, }; export type TranscodeVideoMediaMissionStep = { +step: 'video_ffmpeg_transcode', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +returnCode: ?number, +newPath: ?string, +stats: ?FFmpegStatistics, }; export type VideoGenerateThumbnailMediaMissionStep = { +step: 'video_generate_thumbnail', +success: boolean, +time: number, // ms +returnCode: number, +thumbnailURI: string, }; export type VideoInfo = { +codec: ?string, +dimensions: ?Dimensions, +duration: number, // seconds +format: $ReadOnlyArray, }; export type VideoProbeMediaMissionStep = { +step: 'video_probe', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +validFormat: boolean, +duration: ?number, // seconds +codec: ?string, +format: ?$ReadOnlyArray, +dimensions: ?Dimensions, }; export type ReadFileHeaderMediaMissionStep = { +step: 'read_file_header', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +mime: ?string, +mediaType: ?MediaType, }; export type DetermineFileTypeMediaMissionStep = { +step: 'determine_file_type', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMIME: ?string, +outputMediaType: ?MediaType, +outputFilename: ?string, }; export type FrameCountMediaMissionStep = { +step: 'frame_count', +success: boolean, +exceptionMessage: ?string, +time: number, +path: string, +mime: string, +hasMultipleFrames: ?boolean, }; export type DisposeTemporaryFileMediaMissionStep = { +step: 'dispose_temporary_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type MakeDirectoryMediaMissionStep = { +step: 'make_directory', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type AndroidScanFileMediaMissionStep = { +step: 'android_scan_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, }; export type FetchFileHashMediaMissionStep = { +step: 'fetch_file_hash', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +hash: ?string, }; export type CopyFileMediaMissionStep = { +step: 'copy_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +source: string, +destination: string, }; export type GetOrientationMediaMissionStep = { +step: 'exif_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +orientation: ?number, }; +export type EncryptFileMediaMissionStep = + | { + +step: 'read_plaintext_file', + +file: string, + +time: number, // ms + +success: boolean, + +exceptionMessage: ?string, + } + | { + +step: 'encrypt_data', + +dataSize: number, + +time: number, // ms + +isPadded: boolean, + +success: boolean, + +exceptionMessage: ?string, + } + | { + +step: 'write_encrypted_file', + +file: string, + +time: number, // ms + +success: boolean, + +exceptionMessage: ?string, + }; + export type MediaLibrarySelection = | { +step: 'photo_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, } | { +step: 'video_library', +dimensions: Dimensions, +filename: ?string, +uri: string, +mediaNativeID: ?string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, +duration: number, // seconds }; export type PhotoCapture = { +step: 'photo_capture', +time: number, // ms +dimensions: Dimensions, +filename: string, +uri: string, +captureTime: number, // ms timestamp +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type PhotoPaste = { +step: 'photo_paste', +dimensions: Dimensions, +filename: string, +uri: string, +selectTime: number, // ms timestamp +sendTime: number, // ms timestamp +retries: number, }; export type NativeMediaSelection = | MediaLibrarySelection | PhotoCapture | PhotoPaste; export type MediaMissionStep = | NativeMediaSelection | { +step: 'web_selection', +filename: string, +size: number, // in bytes +mime: string, +selectTime: number, // ms timestamp } | { +step: 'asset_info_fetch', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +localURI: ?string, +orientation: ?number, } | { +step: 'stat_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +fileSize: ?number, } | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | { +step: 'photo_manipulation', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +manipulation: Object, +newMIME: ?string, +newDimensions: ?Dimensions, +newURI: ?string, } | VideoProbeMediaMissionStep | TranscodeVideoMediaMissionStep | VideoGenerateThumbnailMediaMissionStep | DisposeTemporaryFileMediaMissionStep | { +step: 'save_media', +uri: string, +time: number, // ms timestamp } | { +step: 'permissions_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +platform: Platform, +permissions: $ReadOnlyArray, } | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | { +step: 'ios_save_to_library', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, } | { +step: 'fetch_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputURI: string, +uri: string, +size: ?number, +mime: ?string, } | { +step: 'data_uri_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +first255Chars: ?string, } | { +step: 'array_buffer_from_blob', +success: boolean, +exceptionMessage: ?string, +time: number, // ms } | { +step: 'mime_check', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +mime: ?string, } | { +step: 'write_file', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +path: string, +length: number, } | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep + | EncryptFileMediaMissionStep | GetOrientationMediaMissionStep | { +step: 'preload_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: string, +dimensions: ?Dimensions, } | { +step: 'reorient_image', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +uri: ?string, } | { +step: 'upload', +success: boolean, +exceptionMessage: ?string, +time: number, // ms +inputFilename: string, +outputMediaType: ?MediaType, +outputURI: ?string, +outputDimensions: ?Dimensions, +outputLoop: ?boolean, +hasWiFi?: boolean, } | { +step: 'wait_for_capture_uri_unload', +success: boolean, +time: number, // ms +uri: string, }; export type MediaMissionFailure = | { +success: false, +reason: 'no_file_path', } | { +success: false, +reason: 'file_stat_failed', +uri: string, } | { +success: false, +reason: 'photo_manipulation_failed', +size: number, // in bytes } | { +success: false, +reason: 'media_type_fetch_failed', +detectedMIME: ?string, } | { +success: false, +reason: 'mime_type_mismatch', +reportedMediaType: MediaType, +reportedMIME: string, +detectedMIME: string, } | { +success: false, +reason: 'http_upload_failed', +exceptionMessage: ?string, } | { +success: false, +reason: 'video_too_long', +duration: number, // in seconds } | { +success: false, +reason: 'video_probe_failed', } | { +success: false, +reason: 'video_transcode_failed', } | { +success: false, +reason: 'video_generate_thumbnail_failed', } | { +success: false, +reason: 'processing_exception', +time: number, // ms +exceptionMessage: ?string, } | { +success: false, +reason: 'save_unsupported', } | { +success: false, +reason: 'missing_permission', } | { +success: false, +reason: 'make_directory_failed', } | { +success: false, +reason: 'resolve_failed', +uri: string, } | { +success: false, +reason: 'save_to_library_failed', +uri: string, } | { +success: false, +reason: 'fetch_failed', } | { +success: false, +reason: 'data_uri_failed', } | { +success: false, +reason: 'array_buffer_failed', } | { +success: false, +reason: 'mime_check_failed', +mime: ?string, } | { +success: false, +reason: 'write_file_failed', } | { +success: false, +reason: 'fetch_file_hash_failed', } | { +success: false, +reason: 'copy_file_failed', } | { +success: false, +reason: 'exif_fetch_failed', } | { +success: false, +reason: 'reorient_image_failed', } | { +success: false, +reason: 'web_sibling_validation_failed', + } + | { + +success: false, + +reason: 'encryption_failed', }; export type MediaMissionResult = MediaMissionFailure | { +success: true }; export type MediaMission = { +steps: $ReadOnlyArray, +result: MediaMissionResult, +userTime: number, +totalTime: number, }; diff --git a/native/media/encryption-utils.js b/native/media/encryption-utils.js index a03b5370a..84f120141 100644 --- a/native/media/encryption-utils.js +++ b/native/media/encryption-utils.js @@ -1,191 +1,312 @@ // @flow import filesystem from 'react-native-fs'; -import { base64FromIntArray, hexToUintArray } from 'lib/media/data-utils.js'; -import { fileInfoFromData, readableFilename } from 'lib/media/file-utils.js'; -import type { MediaMissionFailure } from 'lib/types/media-types.js'; +import { + base64FromIntArray, + uintArrayToHexString, + hexToUintArray, +} from 'lib/media/data-utils.js'; +import { + replaceExtension, + fileInfoFromData, + readableFilename, +} from 'lib/media/file-utils.js'; +import type { + MediaMissionFailure, + EncryptFileMediaMissionStep, +} from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; -import { unpad } from 'lib/utils/pkcs7-padding.js'; +import { pad, unpad, calculatePaddedLength } from 'lib/utils/pkcs7-padding.js'; import { temporaryDirectoryPath } from './file-utils.js'; import { getFetchableURI } from './identifier-utils.js'; import * as AES from '../utils/aes-crypto-module.js'; const PADDING_THRESHOLD = 5000000; // we don't pad files larger than this +type EncryptedFileResult = { + +success: true, + +uri: string, + +encryptionKey: string, +}; + +/** + * Encrypts a single file and returns the encrypted file URI + * and the encryption key. The encryption key is returned as a hex string. + * The encrypted file is written to the same directory as the original file, + * with the same name, but with the extension ".dat". + * + * @param uri uri to the file to encrypt + * @returns encryption result along with mission steps + */ +async function encryptFile(uri: string): Promise<{ + steps: $ReadOnlyArray, + result: MediaMissionFailure | EncryptedFileResult, +}> { + let success = true, + exceptionMessage; + const steps: EncryptFileMediaMissionStep[] = []; + const destination = replaceExtension(uri, 'dat'); + + // Step 1. Read the file + const startOpenFile = Date.now(); + let data; + try { + const response = await fetch(getFetchableURI(uri)); + const buffer = await response.arrayBuffer(); + data = new Uint8Array(buffer); + } catch (e) { + success = false; + exceptionMessage = getMessageForException(e); + } + steps.push({ + step: 'read_plaintext_file', + file: uri, + time: Date.now() - startOpenFile, + success, + exceptionMessage, + }); + if (!success || !data) { + return { + steps, + result: { success: false, reason: 'fetch_failed' }, + }; + } + + // Step 2. Encrypt the file + const startEncrypt = Date.now(); + const paddedLength = calculatePaddedLength(data.byteLength); + const shouldPad = paddedLength <= PADDING_THRESHOLD; + let key, encryptedData; + try { + const plaintextData = shouldPad ? pad(data) : data; + key = AES.generateKey(); + encryptedData = AES.encrypt(key, plaintextData); + } catch (e) { + success = false; + exceptionMessage = getMessageForException(e); + } + steps.push({ + step: 'encrypt_data', + dataSize: encryptedData?.byteLength ?? -1, + isPadded: shouldPad, + time: Date.now() - startEncrypt, + success, + exceptionMessage, + }); + if (!success || !encryptedData || !key) { + return { + steps, + result: { success: false, reason: 'encryption_failed' }, + }; + } + + // Step 3. Write the encrypted file + const startWriteFile = Date.now(); + try { + const targetBase64 = base64FromIntArray(encryptedData); + await filesystem.writeFile(destination, targetBase64, 'base64'); + } catch (e) { + success = false; + exceptionMessage = getMessageForException(e); + } + steps.push({ + step: 'write_encrypted_file', + file: destination, + time: Date.now() - startWriteFile, + success, + exceptionMessage, + }); + if (!success) { + return { + steps, + result: { success: false, reason: 'write_file_failed' }, + }; + } + + return { + steps, + result: { + success: true, + uri: destination, + encryptionKey: uintArrayToHexString(key), + }, + }; +} + type DecryptFileStep = | { +step: 'fetch_file', +file: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'decrypt_data', +dataSize: number, +time: number, +isPadded: boolean, +success: boolean, +exceptionMessage: ?string, } | { +step: 'write_file', +file: string, +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'create_data_uri', +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, }; type DecryptionFailure = | MediaMissionFailure | { +success: false, +reason: | 'fetch_file_failed' | 'decrypt_data_failed' | 'write_file_failed', +exceptionMessage: ?string, }; async function decryptMedia( holder: string, encryptionKey: string, options: { +destination: 'file' | 'data_uri' }, ): Promise<{ steps: $ReadOnlyArray, result: DecryptionFailure | { success: true, uri: string }, }> { let success = true, exceptionMessage; const steps: DecryptFileStep[] = []; // Step 1. Fetch the file and convert it to a Uint8Array const fetchStartTime = Date.now(); let data; try { const response = await fetch(getFetchableURI(holder)); const buf = await response.arrayBuffer(); data = new Uint8Array(buf); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_file', file: holder, time: Date.now() - fetchStartTime, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_file_failed', exceptionMessage }, }; } // Step 2. Decrypt the data const decryptionStartTime = Date.now(); let plaintextData, decryptedData, isPadded; try { const key = hexToUintArray(encryptionKey); plaintextData = AES.decrypt(key, data); isPadded = plaintextData.byteLength <= PADDING_THRESHOLD; decryptedData = isPadded ? unpad(plaintextData) : plaintextData; } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'decrypt_data', dataSize: decryptedData?.byteLength ?? -1, isPadded: !!isPadded, time: Date.now() - decryptionStartTime, success, exceptionMessage, }); if (!success || !decryptedData) { return { steps, result: { success: false, reason: 'decrypt_data_failed', exceptionMessage, }, }; } // Step 3. Write the file to disk or create a data URI let uri; const writeStartTime = Date.now(); // we need extension for react-native-video to work const { mime } = fileInfoFromData(decryptedData); if (!mime) { return { steps, result: { success: false, reason: 'mime_check_failed', mime, }, }; } const base64 = base64FromIntArray(decryptedData); if (options.destination === 'file') { // if holder is a URL, then we use the last part of the path as the filename const holderSuffix = holder.substring(holder.lastIndexOf('/') + 1); const filename = readableFilename(holderSuffix, mime) || holderSuffix; const targetPath = `${temporaryDirectoryPath}${Date.now()}-${filename}`; try { await filesystem.writeFile(targetPath, base64, 'base64'); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } uri = `file://${targetPath}`; steps.push({ step: 'write_file', file: uri, mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); if (!success) { return { steps, result: { success: false, reason: 'write_file_failed', exceptionMessage, }, }; } } else { uri = `data:${mime};base64,${base64}`; steps.push({ step: 'create_data_uri', mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); } return { steps, result: { success: true, uri }, }; } -export { decryptMedia }; +export { encryptFile, decryptMedia };