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 };