Skip to content

Commit

Permalink
feat(E2EEncryption): File encryption support (#32316)
Browse files Browse the repository at this point in the history
Co-authored-by: Guilherme Gazzo <[email protected]>
Co-authored-by: Marcos Defendi <[email protected]>
  • Loading branch information
3 people authored Jun 22, 2024
1 parent 9fe05d3 commit 161813c
Show file tree
Hide file tree
Showing 45 changed files with 1,444 additions and 138 deletions.
5 changes: 5 additions & 0 deletions .changeset/metal-cats-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": minor
---

Support encrypted files on end-to-end encrypted rooms.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typescript.tsdk": "./node_modules/typescript/lib",
"cSpell.words": [
"autotranslate",
"ciphertext",
"Contextualbar",
"fname",
"Gazzodown",
Expand Down
115 changes: 114 additions & 1 deletion apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, i
import { Meteor } from 'meteor/meteor';

import { isTruthy } from '../../../../lib/isTruthy';
import { omit } from '../../../../lib/utils/omit';
import * as dataExport from '../../../../server/lib/dataExport';
import { eraseRoom } from '../../../../server/methods/eraseRoom';
import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom';
Expand Down Expand Up @@ -141,7 +142,13 @@ API.v1.addRoute(

API.v1.addRoute(
'rooms.upload/:rid',
{ authRequired: true },
{
authRequired: true,
deprecation: {
version: '8.0.0',
alternatives: ['rooms.media'],
},
},
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
Expand Down Expand Up @@ -194,6 +201,112 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'rooms.media/:rid',
{ authRequired: true },
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
return API.v1.unauthorized();
}

const file = await getUploadFormData(
{
request: this.request,
},
{ field: 'file', sizeLimit: settings.get<number>('FileUpload_MaxFileSize') },
);

if (!file) {
throw new Meteor.Error('invalid-field');
}

let { fileBuffer } = file;

const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);

const { fields } = file;

let content;

if (fields.content) {
try {
content = JSON.parse(fields.content);
} catch (e) {
console.error(e);
throw new Meteor.Error('invalid-field-content');
}
}

const details = {
name: file.filename,
size: fileBuffer.length,
type: file.mimetype,
rid: this.urlParams.rid,
userId: this.userId,
content,
expiresAt,
};

const stripExif = settings.get('Message_Attachments_Strip_Exif');
if (stripExif) {
// No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc)
fileBuffer = await Media.stripExifFromBuffer(fileBuffer);
}

const fileStore = FileUpload.getStore('Uploads');
const uploadedFile = await fileStore.insert(details, fileBuffer);

uploadedFile.path = FileUpload.getPath(`${uploadedFile._id}/${encodeURI(uploadedFile.name || '')}`);

await Uploads.updateFileComplete(uploadedFile._id, this.userId, omit(uploadedFile, '_id'));

return API.v1.success({
file: {
_id: uploadedFile._id,
url: uploadedFile.path,
},
});
},
},
);

API.v1.addRoute(
'rooms.mediaConfirm/:rid/:fileId',
{ authRequired: true },
{
async post() {
if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) {
return API.v1.unauthorized();
}

const file = await Uploads.findOneById(this.urlParams.fileId);

if (!file) {
throw new Meteor.Error('invalid-file');
}

file.description = this.bodyParams.description;
delete this.bodyParams.description;

await sendFileMessage(
this.userId,
{ roomId: this.urlParams.rid, file, msgData: this.bodyParams },
{ parseAttachmentsForE2EE: false },
);

await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId);

const message = await Messages.getMessageByFileIdAndUsername(file._id, this.userId);

return API.v1.success({
message,
});
},
},
);

API.v1.addRoute(
'rooms.saveNotification',
{ authRequired: true },
Expand Down
8 changes: 8 additions & 0 deletions apps/meteor/app/e2e/client/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export async function encryptAES(vector, key, data) {
return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data);
}

export async function encryptAESCTR(vector, key, data) {
return crypto.subtle.encrypt({ name: 'AES-CTR', counter: vector, length: 64 }, key, data);
}

export async function decryptRSA(key, data) {
return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data);
}
Expand All @@ -65,6 +69,10 @@ export async function generateAESKey() {
return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']);
}

export async function generateAESCTRKey() {
return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']);
}

export async function generateRSAKey() {
return crypto.subtle.generateKey(
{
Expand Down
127 changes: 81 additions & 46 deletions apps/meteor/app/e2e/client/rocketchat.e2e.room.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Base64 } from '@rocket.chat/base64';
import { Emitter } from '@rocket.chat/emitter';
import { Random } from '@rocket.chat/random';
import EJSON from 'ejson';

import { RoomManager } from '../../../client/lib/RoomManager';
Expand All @@ -23,6 +22,8 @@ import {
importAESKey,
importRSAKey,
readFileAsArrayBuffer,
encryptAESCTR,
generateAESCTRKey,
} from './helper';
import { log, logError } from './logger';
import { e2e } from './rocketchat.e2e';
Expand Down Expand Up @@ -185,20 +186,20 @@ export class E2ERoom extends Emitter {
async decryptSubscription() {
const subscription = Subscriptions.findOne({ rid: this.roomId });

const data = await (subscription.lastMessage?.msg && this.decrypt(subscription.lastMessage.msg));
if (!data?.text) {
if (subscription.lastMessage?.t !== 'e2e') {
this.log('decryptSubscriptions nothing to do');
return;
}

const message = await this.decryptMessage(subscription.lastMessage);

Subscriptions.update(
{
_id: subscription._id,
},
{
$set: {
'lastMessage.msg': data.text,
'lastMessage.e2e': 'done',
lastMessage: message,
},
},
);
Expand Down Expand Up @@ -342,69 +343,96 @@ export class E2ERoom extends Emitter {
}
}

async sha256Hash(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', data)));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

// Encrypts files before upload. I/O is in arraybuffers.
async encryptFile(file) {
if (!this.isSupportedRoomType(this.typeOfRoom)) {
return;
}
// if (!this.isSupportedRoomType(this.typeOfRoom)) {
// return;
// }

const fileArrayBuffer = await readFileAsArrayBuffer(file);

const vector = crypto.getRandomValues(new Uint8Array(16));
const key = await generateAESCTRKey();
let result;
try {
result = await encryptAES(vector, this.groupSessionKey, fileArrayBuffer);
result = await encryptAESCTR(vector, key, fileArrayBuffer);
} catch (error) {
console.log(error);
return this.error('Error encrypting group key: ', error);
}

const output = joinVectorAndEcryptedData(vector, result);
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);

const fileName = await this.sha256Hash(file.name);

const encryptedFile = new File([toArrayBuffer(EJSON.stringify(output))], file.name);
const encryptedFile = new File([toArrayBuffer(result)], fileName);

return encryptedFile;
return {
file: encryptedFile,
key: exportedKey,
iv: Base64.encode(vector),
type: file.type,
};
}

// Decrypt uploaded encrypted files. I/O is in arraybuffers.
async decryptFile(message) {
if (message[0] !== '{') {
return;
}

const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(message));

try {
return await decryptAES(vector, this.groupSessionKey, cipherText);
} catch (error) {
this.error('Error decrypting file: ', error);
async decryptFile(file, key, iv) {
const ivArray = Base64.decode(iv);
const cryptoKey = await window.crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']);

return false;
}
return window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file);
}

// Encrypts messages
async encryptText(data) {
if (!(typeof data === 'function' || (typeof data === 'object' && !!data))) {
data = new TextEncoder('UTF-8').encode(EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) }));
}

if (!this.isSupportedRoomType(this.typeOfRoom)) {
return data;
}

const vector = crypto.getRandomValues(new Uint8Array(16));
let result;

try {
result = await encryptAES(vector, this.groupSessionKey, data);
const result = await encryptAES(vector, this.groupSessionKey, data);
return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result));
} catch (error) {
return this.error('Error encrypting message: ', error);
this.error('Error encrypting message: ', error);
throw error;
}
}

return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result));
// Helper function for encryption of content
async encryptMessageContent(contentToBeEncrypted) {
const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted));

return {
algorithm: 'rc.v1.aes-sha2',
ciphertext: await this.encryptText(data),
};
}

// Helper function for encryption of content
async encryptMessage(message) {
const { msg, attachments, ...rest } = message;

const content = await this.encryptMessageContent({ msg, attachments });

return {
...rest,
content,
t: 'e2e',
e2e: 'pending',
};
}

// Helper function for encryption of messages
encrypt(message) {
if (!this.isSupportedRoomType(this.typeOfRoom)) {
return;
}

const ts = new Date();

const data = new TextEncoder('UTF-8').encode(
Expand All @@ -419,31 +447,38 @@ export class E2ERoom extends Emitter {
return this.encryptText(data);
}

// Decrypt messages
async decryptContent(data) {
if (data.content && data.content.algorithm === 'rc.v1.aes-sha2') {
const content = await this.decrypt(data.content.ciphertext);
Object.assign(data, content);
}

return data;
}

// Decrypt messages
async decryptMessage(message) {
if (message.t !== 'e2e' || message.e2e === 'done') {
return message;
}

const data = await this.decrypt(message.msg);
if (message.msg) {
const data = await this.decrypt(message.msg);

if (!data?.text) {
return message;
if (data?.text) {
message.msg = data.text;
}
}

message = await this.decryptContent(message);

return {
...message,
msg: data.text,
e2e: 'done',
};
}

async decrypt(message) {
if (!this.isSupportedRoomType(this.typeOfRoom)) {
return message;
}

const keyID = message.slice(0, 12);

if (keyID !== this.keyID) {
Expand Down
Loading

0 comments on commit 161813c

Please sign in to comment.