diff --git a/web/media/encryption-utils.js b/web/media/encryption-utils.js index 77d4c9643..824095be8 100644 --- a/web/media/encryption-utils.js +++ b/web/media/encryption-utils.js @@ -1,239 +1,253 @@ // @flow import invariant from 'invariant'; +import { thumbHashToDataURL } from 'thumbhash'; import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js'; import { fileInfoFromData } from 'lib/media/file-utils.js'; import { fetchableMediaURI } from 'lib/media/media-utils.js'; import type { MediaMissionFailure, MediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { calculatePaddedLength, pad, unpad } from 'lib/utils/pkcs7-padding.js'; import * as AES from './aes-crypto-utils.js'; +import { base64DecodeBuffer } from '../utils/base64-utils.js'; const PADDING_THRESHOLD = 5000000; // 5MB type EncryptFileResult = { +success: true, +file: File, +uri: string, +encryptionKey: string, +sha256Hash: string, }; async function encryptFile(input: File): Promise<{ steps: $ReadOnlyArray, result: EncryptFileResult | MediaMissionFailure, }> { const steps = []; let success = true, exceptionMessage; // Step 1: Read the file into an ArrayBuffer let data; const arrayBufferStart = Date.now(); try { const inputBuffer = await input.arrayBuffer(); data = new Uint8Array(inputBuffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'array_buffer_from_blob', success, exceptionMessage, time: Date.now() - arrayBufferStart, }); if (!success || !data) { return { steps, result: { success: false, reason: 'array_buffer_failed' } }; } // Step 2: Encrypt the data const startEncrypt = Date.now(); const paddedLength = calculatePaddedLength(data.length); const shouldPad = paddedLength <= PADDING_THRESHOLD; let key, encryptedData, sha256; try { const plaintextData = shouldPad ? pad(data) : data; key = await AES.generateKey(); encryptedData = await AES.encrypt(key, plaintextData); const hashBytes = await crypto.subtle.digest('SHA-256', encryptedData); sha256 = btoa(String.fromCharCode(...new Uint8Array(hashBytes))); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'encrypt_data', dataSize: encryptedData?.byteLength ?? -1, isPadded: shouldPad, time: Date.now() - startEncrypt, sha256, success, exceptionMessage, }); if (encryptedData && !sha256) { return { steps, result: { success: false, reason: 'digest_failed' } }; } if (!success || !encryptedData || !key || !sha256) { return { steps, result: { success: false, reason: 'encryption_failed' } }; } // Step 3: Create a File from the encrypted data const output = new File([encryptedData], input.name, { type: input.type }); return { steps, result: { success: true, file: output, uri: URL.createObjectURL(output), encryptionKey: uintArrayToHexString(key), sha256Hash: sha256, }, }; } type DecryptFileStep = | { +step: 'fetch_buffer', +url: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'decrypt_data', +dataSize: number, +time: number, +isPadded: boolean, +success: boolean, +exceptionMessage: ?string, } | { +step: 'save_blob', +objectURL: ?string, +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, }; type DecryptionFailure = | MediaMissionFailure | { +success: false, +reason: 'decrypt_data_failed' | 'save_blob_failed', }; /** * Fetches the encrypted media for given {@link holder}, decrypts it, * and stores it in a blob. Returns the object URL of the blob. * * The returned object URL should be revoked when the media is no longer needed. */ async function decryptMedia( holder: string, encryptionKey: string, ): Promise<{ steps: $ReadOnlyArray, result: { success: true, uri: string } | DecryptionFailure, }> { let success = true; let exceptionMessage; const steps: DecryptFileStep[] = []; // Step 1 - Fetch the encrypted media and convert it to a Uint8Array let data; const fetchStartTime = Date.now(); const url = fetchableMediaURI(holder); try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); } const buffer = await response.arrayBuffer(); data = new Uint8Array(buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_buffer', url, time: Date.now() - fetchStartTime, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_failed' }, }; } // Step 2 - Decrypt the data let decryptedData; const decryptStartTime = Date.now(); try { const keyBytes = hexToUintArray(encryptionKey); const plaintext = await AES.decrypt(keyBytes, data); decryptedData = plaintext.byteLength > PADDING_THRESHOLD ? plaintext : unpad(plaintext); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'decrypt_data', dataSize: decryptedData?.byteLength ?? -1, time: Date.now() - decryptStartTime, isPadded: data.byteLength > PADDING_THRESHOLD, success, exceptionMessage, }); if (!success || !decryptedData) { return { steps, result: { success: false, reason: 'decrypt_data_failed' } }; } // Step 3 - Create a blob from the decrypted data and return it const saveStartTime = Date.now(); const { mime } = fileInfoFromData(decryptedData); if (!mime) { return { steps, result: { success: false, reason: 'mime_check_failed', mime }, }; } let objectURL; try { invariant(mime, 'mime type should be defined'); const decryptedBlob = new Blob([decryptedData], { type: mime }); objectURL = URL.createObjectURL(decryptedBlob); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'save_blob', objectURL, mimeType: mime, time: Date.now() - saveStartTime, success, exceptionMessage, }); if (!success || !objectURL) { return { steps, result: { success: false, reason: 'save_blob_failed' }, }; } return { steps, result: { success: true, uri: objectURL } }; } -export { encryptFile, decryptMedia }; +async function decryptThumbhashToDataURL( + encryptedThumbHash: string, + keyHex: string, +): Promise { + const encryptedData = base64DecodeBuffer(encryptedThumbHash); + const thumbhashBytes = await AES.decrypt( + hexToUintArray(keyHex), + encryptedData, + ); + return thumbHashToDataURL(thumbhashBytes); +} + +export { encryptFile, decryptMedia, decryptThumbhashToDataURL }; diff --git a/web/media/media-utils.js b/web/media/media-utils.js index 6c09e1497..859be2055 100644 --- a/web/media/media-utils.js +++ b/web/media/media-utils.js @@ -1,196 +1,233 @@ // @flow +import * as React from 'react'; +import { thumbHashToDataURL } from 'thumbhash'; + import type { MediaType, Dimensions, MediaMissionStep, MediaMissionFailure, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { probeFile } from './blob-utils.js'; +import { decryptThumbhashToDataURL } from './encryption-utils.js'; import { getOrientation } from './image-utils.js'; +import { base64DecodeBuffer } from '../utils/base64-utils.js'; async function preloadImage(uri: string): Promise<{ steps: $ReadOnlyArray, result: ?Image, }> { let image, exceptionMessage; const start = Date.now(); try { image = await new Promise((resolve, reject) => { const img = new Image(); img.src = uri; img.onload = () => { resolve(img); }; img.onerror = e => { reject(e); }; }); } catch (e) { exceptionMessage = getMessageForException(e); } const dimensions = image ? { height: image.height, width: image.width } : null; const step = { step: 'preload_image', success: !!image, exceptionMessage, time: Date.now() - start, uri, dimensions, }; return { steps: [step], result: image }; } type ProcessFileSuccess = { success: true, uri: string, dimensions: ?Dimensions, }; async function processFile( file: File, exifRotate: boolean, ): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | ProcessFileSuccess, }> { const initialURI = URL.createObjectURL(file); if (!exifRotate) { const { steps, result } = await preloadImage(initialURI); let dimensions; if (result) { const { width, height } = result; dimensions = { width, height }; } return { steps, result: { success: true, uri: initialURI, dimensions } }; } const [preloadResponse, orientationStep] = await Promise.all([ preloadImage(initialURI), getOrientation(file), ]); const { steps: preloadSteps, result: image } = preloadResponse; const steps = [...preloadSteps, orientationStep]; if (!image) { return { steps, result: { success: true, uri: initialURI, dimensions: undefined }, }; } if (!orientationStep.success) { return { steps, result: { success: false, reason: 'exif_fetch_failed' } }; } const { orientation } = orientationStep; const dimensions = !!orientation && orientation > 4 ? { width: image.height, height: image.width } : { width: image.width, height: image.height }; if (!orientation || orientation === 1) { return { steps, result: { success: true, uri: initialURI, dimensions } }; } let reorientedBlob, reorientExceptionMessage; const reorientStart = Date.now(); try { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.height = dimensions.height; canvas.width = dimensions.width; if (orientation === 2) { context.transform(-1, 0, 0, 1, dimensions.width, 0); } else if (orientation === 3) { context.transform(-1, 0, 0, -1, dimensions.width, dimensions.height); } else if (orientation === 4) { context.transform(1, 0, 0, -1, 0, dimensions.height); } else if (orientation === 5) { context.transform(0, 1, 1, 0, 0, 0); } else if (orientation === 6) { context.transform(0, 1, -1, 0, dimensions.width, 0); } else if (orientation === 7) { context.transform(0, -1, -1, 0, dimensions.width, dimensions.height); } else if (orientation === 8) { context.transform(0, -1, 1, 0, 0, dimensions.height); } else { context.transform(1, 0, 0, 1, 0, 0); } context.drawImage(image, 0, 0); reorientedBlob = await new Promise(resolve => canvas.toBlob(blobResult => resolve(blobResult)), ); } catch (e) { reorientExceptionMessage = getMessageForException(e); } URL.revokeObjectURL(initialURI); const uri = reorientedBlob && URL.createObjectURL(reorientedBlob); steps.push({ step: 'reorient_image', success: !!reorientedBlob, exceptionMessage: reorientExceptionMessage, time: Date.now() - reorientStart, uri, }); if (!uri) { return { steps, result: { success: false, reason: 'reorient_image_failed' }, }; } return { steps, result: { success: true, uri, dimensions } }; } type FileValidationSuccess = { success: true, file: File, mediaType: MediaType, uri: string, dimensions: ?Dimensions, }; async function validateFile( file: File, exifRotate: boolean, ): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | FileValidationSuccess, }> { const [probeResponse, processResponse] = await Promise.all([ probeFile(file), processFile(file, exifRotate), ]); const { steps: probeSteps, result: probeResult } = probeResponse; const { steps: processSteps, result: processResult } = processResponse; const steps = [...probeSteps, ...processSteps]; if (!probeResult.success) { return { steps, result: probeResult }; } const { file: fixedFile, mediaType } = probeResult; if (!processResult.success) { return { steps, result: processResult }; } const { dimensions, uri } = processResult; return { steps, result: { success: true, file: fixedFile, mediaType, uri, dimensions, }, }; } -export { preloadImage, validateFile }; +function usePlaceholder(thumbHash: ?string, encryptionKey: ?string): ?string { + const [placeholder, setPlaceholder] = React.useState(null); + + React.useEffect(() => { + if (!thumbHash) { + setPlaceholder(null); + return; + } + + if (!encryptionKey) { + const binaryThumbHash = base64DecodeBuffer(thumbHash); + const placeholderImage = thumbHashToDataURL(binaryThumbHash); + setPlaceholder(placeholderImage); + return; + } + + (async () => { + try { + const decryptedThumbHash = await decryptThumbhashToDataURL( + thumbHash, + encryptionKey, + ); + setPlaceholder(decryptedThumbHash); + } catch { + setPlaceholder(null); + } + })(); + }, [thumbHash, encryptionKey]); + + return placeholder; +} + +export { preloadImage, validateFile, usePlaceholder };