diff --git a/native/media/encryption-utils.js b/native/media/encryption-utils.js index 84f120141..c4c839006 100644 --- a/native/media/encryption-utils.js +++ b/native/media/encryption-utils.js @@ -1,312 +1,382 @@ // @flow +import invariant from 'invariant'; import filesystem from 'react-native-fs'; import { base64FromIntArray, uintArrayToHexString, hexToUintArray, } from 'lib/media/data-utils.js'; import { replaceExtension, fileInfoFromData, readableFilename, + pathFromURI, } from 'lib/media/file-utils.js'; import type { MediaMissionFailure, + MediaMissionStep, EncryptFileMediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { pad, unpad, calculatePaddedLength } from 'lib/utils/pkcs7-padding.js'; import { temporaryDirectoryPath } from './file-utils.js'; import { getFetchableURI } from './identifier-utils.js'; +import type { MediaResult } from './media-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), }, }; } +/** + * Encrypts a single photo or video. Replaces the uploadURI with the encrypted + * file URI. Attaches `encryptionKey` to the result. Changes the mediaType to + * `encrypted_photo` or `encrypted_video`. + * + * @param preprocessedMedia - Result of `processMedia()` call + * @returns a `preprocessedMedia` param, but with encryption applied + */ +async function encryptMedia(preprocessedMedia: MediaResult): Promise<{ + result: MediaResult | MediaMissionFailure, + steps: $ReadOnlyArray, +}> { + invariant(preprocessedMedia.success, 'encryptMedia called on failure result'); + invariant( + preprocessedMedia.mediaType === 'photo' || + preprocessedMedia.mediaType === 'video', + 'encryptMedia should only be called on unencrypted photos and videos', + ); + const { uploadURI } = preprocessedMedia; + const steps = []; + + // Encrypt the media file + const { steps: encryptionSteps, result: encryptionResult } = + await encryptFile(uploadURI); + steps.push(...encryptionSteps); + + if (!encryptionResult.success) { + return { steps, result: encryptionResult }; + } + + if (preprocessedMedia.mediaType === 'photo') { + return { + steps, + result: { + ...preprocessedMedia, + mediaType: 'encrypted_photo', + uploadURI: encryptionResult.uri, + encryptionKey: encryptionResult.encryptionKey, + shouldDisposePath: pathFromURI(encryptionResult.uri), + }, + }; + } + + // For videos, we also need to encrypt the thumbnail + const { steps: thumbnailEncryptionSteps, result: thumbnailEncryptionResult } = + await encryptFile(preprocessedMedia.uploadThumbnailURI); + steps.push(...thumbnailEncryptionSteps); + + if (!thumbnailEncryptionResult.success) { + return { steps, result: thumbnailEncryptionResult }; + } + + return { + steps, + result: { + ...preprocessedMedia, + mediaType: 'encrypted_video', + uploadURI: encryptionResult.uri, + encryptionKey: encryptionResult.encryptionKey, + uploadThumbnailURI: thumbnailEncryptionResult.uri, + thumbnailEncryptionKey: thumbnailEncryptionResult.encryptionKey, + shouldDisposePath: pathFromURI(encryptionResult.uri), + }, + }; +} + 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 { encryptFile, decryptMedia }; +export { encryptMedia, decryptMedia }; diff --git a/native/media/media-utils.js b/native/media/media-utils.js index cc6f3f589..63817189c 100644 --- a/native/media/media-utils.js +++ b/native/media/media-utils.js @@ -1,251 +1,264 @@ // @flow import invariant from 'invariant'; import { Image } from 'react-native'; import { pathFromURI, sanitizeFilename } from 'lib/media/file-utils.js'; import type { Dimensions, MediaMissionStep, MediaMissionFailure, NativeMediaSelection, } from 'lib/types/media-types.js'; import { fetchFileInfo } from './file-utils.js'; import { processImage } from './image-utils.js'; import { saveMedia } from './save-media.js'; import { processVideo } from './video-utils.js'; type MediaProcessConfig = { +hasWiFi: boolean, // Blocks return until we can confirm result has the correct MIME +finalFileHeaderCheck?: boolean, +onTranscodingProgress: (percent: number) => void, }; type SharedMediaResult = { +success: true, +uploadURI: string, +shouldDisposePath: ?string, +filename: string, +mime: string, +dimensions: Dimensions, }; -type MediaResult = +export type MediaResult = | { +mediaType: 'photo', ...SharedMediaResult } | { +mediaType: 'video', ...SharedMediaResult, +uploadThumbnailURI: string, +loop: boolean, + } + | { + +mediaType: 'encrypted_photo', + ...SharedMediaResult, + +encryptionKey: string, + } + | { + +mediaType: 'encrypted_video', + ...SharedMediaResult, + +encryptionKey: string, + +thumbnailEncryptionKey: string, + +uploadThumbnailURI: string, + +loop: boolean, }; function processMedia( selection: NativeMediaSelection, config: MediaProcessConfig, ): { resultPromise: Promise, reportPromise: Promise<$ReadOnlyArray>, } { let resolveResult; const sendResult = result => { if (resolveResult) { resolveResult(result); } }; const reportPromise = innerProcessMedia(selection, config, sendResult); const resultPromise = new Promise(resolve => { resolveResult = resolve; }); return { reportPromise, resultPromise }; } async function innerProcessMedia( selection: NativeMediaSelection, config: MediaProcessConfig, sendResult: (result: MediaMissionFailure | MediaResult) => void, ): Promise<$ReadOnlyArray> { let initialURI = null, uploadURI = null, uploadThumbnailURI = null, dimensions = selection.dimensions, mediaType = null, mime = null, loop = false, resultReturned = false; const returnResult = (failure?: MediaMissionFailure) => { invariant( !resultReturned, 'returnResult called twice in innerProcessMedia', ); resultReturned = true; if (failure) { sendResult(failure); return; } invariant( uploadURI && mime && mediaType, 'missing required fields in returnResult', ); const shouldDisposePath = initialURI !== uploadURI ? pathFromURI(uploadURI) : null; const filename = sanitizeFilename(selection.filename, mime); if (mediaType === 'video') { invariant(uploadThumbnailURI, 'video should have uploadThumbnailURI'); sendResult({ success: true, uploadURI, uploadThumbnailURI, shouldDisposePath, filename, mime, mediaType, dimensions, loop, }); } else { sendResult({ success: true, uploadURI, shouldDisposePath, filename, mime, mediaType, dimensions, }); } }; const steps = [], completeBeforeFinish = []; const finish = async (failure?: MediaMissionFailure) => { if (!resultReturned) { returnResult(failure); } await Promise.all(completeBeforeFinish); return steps; }; if (selection.captureTime && selection.retries === 0) { const { uri } = selection; invariant( pathFromURI(uri), `captured URI ${uri} should use file:// scheme`, ); completeBeforeFinish.push( (async () => { const { reportPromise } = saveMedia(uri); const saveMediaSteps = await reportPromise; steps.push(...saveMediaSteps); })(), ); } const possiblyPhoto = selection.step.startsWith('photo_'); const mediaNativeID = selection.mediaNativeID ? selection.mediaNativeID : null; const { steps: fileInfoSteps, result: fileInfoResult } = await fetchFileInfo( selection.uri, { mediaNativeID }, { orientation: possiblyPhoto, mime: true, mediaType: true, }, ); steps.push(...fileInfoSteps); if (!fileInfoResult.success) { return await finish(fileInfoResult); } const { orientation, fileSize } = fileInfoResult; ({ uri: initialURI, mime, mediaType } = fileInfoResult); if (!mime || !mediaType) { return await finish({ success: false, reason: 'media_type_fetch_failed', detectedMIME: mime, }); } if (mediaType === 'video') { const { steps: videoSteps, result: videoResult } = await processVideo( { uri: initialURI, mime, filename: selection.filename, fileSize, dimensions, hasWiFi: config.hasWiFi, }, { onTranscodingProgress: config.onTranscodingProgress, }, ); steps.push(...videoSteps); if (!videoResult.success) { return await finish(videoResult); } ({ uri: uploadURI, thumbnailURI: uploadThumbnailURI, mime, dimensions, loop, } = videoResult); } else if (mediaType === 'photo') { const { steps: imageSteps, result: imageResult } = await processImage({ uri: initialURI, dimensions, mime, fileSize, orientation, }); steps.push(...imageSteps); if (!imageResult.success) { return await finish(imageResult); } ({ uri: uploadURI, mime, dimensions } = imageResult); } else { invariant(false, `unknown mediaType ${mediaType}`); } if (uploadURI === initialURI) { return await finish(); } if (!config.finalFileHeaderCheck) { returnResult(); } const { steps: finalFileInfoSteps, result: finalFileInfoResult } = await fetchFileInfo(uploadURI, undefined, { mime: true }); steps.push(...finalFileInfoSteps); if (!finalFileInfoResult.success) { return await finish(finalFileInfoResult); } if (finalFileInfoResult.mime && finalFileInfoResult.mime !== mime) { return await finish({ success: false, reason: 'mime_type_mismatch', reportedMediaType: mediaType, reportedMIME: mime, detectedMIME: finalFileInfoResult.mime, }); } return await finish(); } function getDimensions(uri: string): Promise { return new Promise((resolve, reject) => { Image.getSize( uri, (width: number, height: number) => resolve({ height, width }), reject, ); }); } export { processMedia, getDimensions };