diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index 3ef86788c..2ec3ae814 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,62 +1,62 @@ // @flow import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; import { qrCodeLinkURL } from 'lib/facts/links.js'; +import { generateKeyCommon } from 'lib/media/aes-crypto-utils-common.js'; import { uintArrayToHexString } from 'lib/media/data-utils.js'; import css from './qr-code-login.css'; -import { generateKey } from '../media/aes-crypto-utils.js'; import { useSelector } from '../redux/redux-utils.js'; function QrCodeLogin(): React.Node { const [qrCodeValue, setQrCodeValue] = React.useState(); const ed25519Key = useSelector( state => state.cryptoStore.primaryIdentityKeys?.ed25519, ); const generateQRCode = React.useCallback(async () => { try { if (!ed25519Key) { return; } - const rawAESKey: Uint8Array = await generateKey(); + const rawAESKey: Uint8Array = await generateKeyCommon(crypto); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); const url = qrCodeLinkURL(aesKeyAsHexString, ed25519Key); setQrCodeValue(url); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [ed25519Key]); React.useEffect(() => { generateQRCode(); }, [generateQRCode]); return (
Log in to Comm
Open the Comm app on your phone and scan the QR code below
How to find the scanner:
Go to Profile
Select Linked devices
Click Add on the top right
); } export default QrCodeLogin; diff --git a/web/media/aes-crypto-utils.js b/web/media/aes-crypto-utils.js deleted file mode 100644 index e65565596..000000000 --- a/web/media/aes-crypto-utils.js +++ /dev/null @@ -1,74 +0,0 @@ -// @flow - -const KEY_SIZE = 32; // bytes -const IV_LENGTH = 12; // bytes - unique Initialization Vector (nonce) -const TAG_LENGTH = 16; // bytes - GCM auth tag - -async function generateKey(): Promise { - const algorithm = { name: 'AES-GCM', length: 256 }; - const key = await crypto.subtle.generateKey(algorithm, true, [ - 'encrypt', - 'decrypt', - ]); - const keyData = await crypto.subtle.exportKey('raw', key); - return new Uint8Array(keyData); -} - -async function encrypt( - keyBytes: Uint8Array, - plaintext: Uint8Array, -): Promise { - if (keyBytes.length !== KEY_SIZE) { - throw new Error('Invalid AES key size'); - } - - // we're creating the buffer now so we can avoid reallocating it later - const outputBuffer = new ArrayBuffer( - plaintext.length + IV_LENGTH + TAG_LENGTH, - ); - const ivBytes = new Uint8Array(outputBuffer, 0, IV_LENGTH); - const iv = crypto.getRandomValues(ivBytes); - - const algorithm = { name: 'AES-GCM', iv: iv, tagLength: TAG_LENGTH * 8 }; - const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, [ - 'encrypt', - ]); - const ciphertextWithTag = await crypto.subtle.encrypt( - algorithm, - key, - plaintext, - ); - - const result = new Uint8Array(outputBuffer); - result.set(new Uint8Array(ciphertextWithTag), iv.length); - return result; -} - -async function decrypt( - keyBytes: Uint8Array, - sealedData: Uint8Array, -): Promise { - if (keyBytes.length !== KEY_SIZE) { - throw new Error('Invalid AES key size'); - } - if (sealedData.length < IV_LENGTH + TAG_LENGTH) { - throw new Error('Invalid ciphertext size'); - } - - const iv = sealedData.subarray(0, IV_LENGTH); - const ciphertextWithTag = sealedData.subarray(IV_LENGTH); - - const algorithm = { name: 'AES-GCM', iv, tagLength: TAG_LENGTH * 8 }; - const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, [ - 'decrypt', - ]); - - const plaintextBuffer = await crypto.subtle.decrypt( - algorithm, - key, - ciphertextWithTag, - ); - return new Uint8Array(plaintextBuffer); -} - -export { generateKey, encrypt, decrypt }; diff --git a/web/media/aes-crypto-utils.test.js b/web/media/aes-crypto-utils.test.js index 7bee8a2b6..48214caaa 100644 --- a/web/media/aes-crypto-utils.test.js +++ b/web/media/aes-crypto-utils.test.js @@ -1,88 +1,100 @@ // @flow -import { generateKey, encrypt, decrypt } from './aes-crypto-utils.js'; +import { + generateKeyCommon, + encryptCommon, + decryptCommon, +} from 'lib/media/aes-crypto-utils-common.js'; // some mock data const testPlaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); const testEncryptionKey = new Uint8Array([ 3, 183, 109, 170, 201, 127, 55, 253, 114, 23, 75, 24, 168, 44, 150, 92, 148, 60, 26, 126, 45, 237, 92, 10, 63, 89, 226, 77, 109, 29, 238, 143, ]); const testSealedData = new Uint8Array([ 172, 195, 202, 136, 163, 60, 134, 85, 41, 48, 44, 27, 181, 109, 35, 254, 150, 78, 255, 5, 8, 28, 4, 208, 206, 117, 148, 66, 196, 247, 61, 11, 3, 118, 116, 5, 112, 185, 142, ]); const randomData = new Uint8Array( new Array(100).fill(0).map(() => Math.floor(Math.random() * 255)), ); describe('generateKey', () => { it('generates 32-byte AES key', async () => { - const key = await generateKey(); + const key = await generateKeyCommon(crypto); expect(key.length).toBe(32); }); }); describe('encrypt', () => { it('generates ciphertext with IV and tag included', async () => { - const encrypted = await encrypt(testEncryptionKey, testPlaintext); + const encrypted = await encryptCommon( + crypto, + testEncryptionKey, + testPlaintext, + ); // IV and tag are randomly generated, so we can't check the exact value // IV + plaintext + tag = 12 + 11 + 16 = 39 expect(encrypted.length).toBe(testPlaintext.length + 12 + 16); }); it('is decryptable by decrypt()', async () => { - const key = await generateKey(); - const encrypted = await encrypt(key, randomData); - const decrypted = await decrypt(key, encrypted); + const key = await generateKeyCommon(crypto); + const encrypted = await encryptCommon(crypto, key, randomData); + const decrypted = await decryptCommon(crypto, key, encrypted); expect(decrypted).toEqual(randomData); }); }); describe('decrypt', () => { it('decrypts ciphertext', async () => { - const decrypted = await decrypt(testEncryptionKey, testSealedData); + const decrypted = await decryptCommon( + crypto, + testEncryptionKey, + testSealedData, + ); expect(decrypted).toEqual(testPlaintext); }); it('fails with wrong key', async () => { - const key = await generateKey(); - const encrypted = await encrypt(key, randomData); + const key = await generateKeyCommon(crypto); + const encrypted = await encryptCommon(crypto, key, randomData); - const wrongKey = await generateKey(); - await expect(decrypt(wrongKey, encrypted)).rejects.toThrow(); + const wrongKey = await generateKeyCommon(crypto); + await expect(decryptCommon(crypto, wrongKey, encrypted)).rejects.toThrow(); }); it('fails with wrong ciphertext', async () => { - const key = await generateKey(); - const encrypted = await encrypt(key, randomData); + const key = await generateKeyCommon(crypto); + const encrypted = await encryptCommon(crypto, key, randomData); // change the first byte of the ciphertext (it's 13th byte in the buffer) // first 12 bytes are IV, so changing the first byte of the ciphertext encrypted[12] = encrypted[12] ^ 1; - await expect(decrypt(key, encrypted)).rejects.toThrow(); + await expect(decryptCommon(crypto, key, encrypted)).rejects.toThrow(); }); it('fails with wrong IV', async () => { - const key = await generateKey(); - const encrypted = await encrypt(key, randomData); + const key = await generateKeyCommon(crypto); + const encrypted = await encryptCommon(crypto, key, randomData); // change the first byte of the IV (it's 1st byte in the buffer) encrypted[0] = encrypted[0] ^ 1; - await expect(decrypt(key, encrypted)).rejects.toThrow(); + await expect(decryptCommon(crypto, key, encrypted)).rejects.toThrow(); }); it('fails with wrong tag', async () => { - const key = await generateKey(); - const encrypted = await encrypt(key, randomData); + const key = await generateKeyCommon(crypto); + const encrypted = await encryptCommon(crypto, key, randomData); // change the last byte of the tag (tag is the last 16 bytes of the buffer) encrypted[encrypted.length - 1] = encrypted[encrypted.length - 1] ^ 1; - await expect(decrypt(key, encrypted)).rejects.toThrow(); + await expect(decryptCommon(crypto, key, encrypted)).rejects.toThrow(); }); }); diff --git a/web/media/encryption-utils.js b/web/media/encryption-utils.js index e2fa5b335..1c18f9f4a 100644 --- a/web/media/encryption-utils.js +++ b/web/media/encryption-utils.js @@ -1,253 +1,254 @@ // @flow import invariant from 'invariant'; import { thumbHashToDataURL } from 'thumbhash'; +import * as AES from 'lib/media/aes-crypto-utils-common.js'; 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); + key = await AES.generateKeyCommon(crypto); + encryptedData = await AES.encryptCommon(crypto, 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 blobURI}, 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( blobURI: 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(blobURI); 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); + const plaintext = await AES.decryptCommon(crypto, 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 } }; } async function decryptThumbhashToDataURL( encryptedThumbHash: string, keyHex: string, ): Promise { const encryptedData = base64DecodeBuffer(encryptedThumbHash); - const thumbhashBytes = await AES.decrypt( + const thumbhashBytes = await AES.decryptCommon( + crypto, hexToUintArray(keyHex), encryptedData, ); return thumbHashToDataURL(thumbhashBytes); } export { encryptFile, decryptMedia, decryptThumbhashToDataURL }; diff --git a/web/media/image-utils.js b/web/media/image-utils.js index 827cdff21..906f06511 100644 --- a/web/media/image-utils.js +++ b/web/media/image-utils.js @@ -1,119 +1,120 @@ // @flow import EXIF from 'exif-js'; import { rgbaToThumbHash } from 'thumbhash'; +import * as AES from 'lib/media/aes-crypto-utils-common.js'; import { hexToUintArray } from 'lib/media/data-utils.js'; import type { GetOrientationMediaMissionStep, MediaMissionFailure, MediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; -import * as AES from './aes-crypto-utils.js'; import { preloadImage } from './media-utils.js'; import { base64EncodeBuffer } from '../utils/base64-utils.js'; function getEXIFOrientation(file: File): Promise { return new Promise(resolve => { EXIF.getData(file, function () { resolve(EXIF.getTag(this, 'Orientation')); }); }); } async function getOrientation( file: File, ): Promise { let orientation, success = false, exceptionMessage; const start = Date.now(); try { orientation = await getEXIFOrientation(file); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'exif_fetch', success, exceptionMessage, time: Date.now() - start, orientation, }; } type GenerateThumbhashResult = { +success: true, +thumbHash: string, }; /** * Generate a thumbhash for a given image file. If `encryptionKey` is provided, * the thumbhash string will be encrypted with it. */ async function generateThumbHash( file: File, encryptionKey: ?string = null, ): Promise<{ +steps: $ReadOnlyArray, +result: GenerateThumbhashResult | MediaMissionFailure, }> { const steps = []; const initialURI = URL.createObjectURL(file); const { steps: preloadSteps, result: image } = await preloadImage(initialURI); steps.push(...preloadSteps); if (!image) { return { steps, result: { success: false, reason: 'preload_image_failed' }, }; } let binaryThumbHash, thumbHashString, exceptionMessage; try { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); // rescale to 100px max as thumbhash doesn't need more const scale = 100 / Math.max(image.width, image.height); canvas.width = Math.round(image.width * scale); canvas.height = Math.round(image.height * scale); context.drawImage(image, 0, 0, canvas.width, canvas.height); const pixels = context.getImageData(0, 0, canvas.width, canvas.height); binaryThumbHash = rgbaToThumbHash(pixels.width, pixels.height, pixels.data); thumbHashString = base64EncodeBuffer(binaryThumbHash); } catch (e) { exceptionMessage = getMessageForException(e); } finally { URL.revokeObjectURL(initialURI); } steps.push({ step: 'generate_thumbhash', success: !!thumbHashString && !exceptionMessage, exceptionMessage, thumbHash: thumbHashString, }); if (!binaryThumbHash || !thumbHashString || exceptionMessage) { return { steps, result: { success: false, reason: 'thumbhash_failed' } }; } if (encryptionKey) { try { - const encryptedThumbHash = await AES.encrypt( + const encryptedThumbHash = await AES.encryptCommon( + crypto, hexToUintArray(encryptionKey), binaryThumbHash, ); thumbHashString = base64EncodeBuffer(encryptedThumbHash); } catch { return { steps, result: { success: false, reason: 'encryption_failed' } }; } } return { steps, result: { success: true, thumbHash: thumbHashString } }; } export { getOrientation, generateThumbHash };