You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
532 lines
12 KiB
532 lines
12 KiB
import { Meteor } from 'meteor/meteor';
|
|
import { Random } from 'meteor/random';
|
|
import { ReactiveVar } from 'meteor/reactive-var';
|
|
import { Tracker } from 'meteor/tracker';
|
|
import { EJSON } from 'meteor/ejson';
|
|
import { FlowRouter } from 'meteor/kadira:flow-router';
|
|
import { Rooms, Subscriptions, Messages } from 'meteor/rocketchat:models';
|
|
import { promises } from 'meteor/rocketchat:promises';
|
|
import { settings } from 'meteor/rocketchat:settings';
|
|
import { Notifications } from 'meteor/rocketchat:notifications';
|
|
import { Layout, call } from 'meteor/rocketchat:ui-utils';
|
|
import { TAPi18n } from 'meteor/tap:i18n';
|
|
import { E2ERoom } from './rocketchat.e2e.room';
|
|
import {
|
|
Deferred,
|
|
toString,
|
|
toArrayBuffer,
|
|
joinVectorAndEcryptedData,
|
|
splitVectorAndEcryptedData,
|
|
encryptAES,
|
|
decryptAES,
|
|
generateRSAKey,
|
|
exportJWKKey,
|
|
importRSAKey,
|
|
importRawKey,
|
|
deriveKey,
|
|
} from './helper';
|
|
|
|
import './events.js';
|
|
import './accountEncryption.html';
|
|
import './accountEncryption.js';
|
|
|
|
let failedToDecodeKey = false;
|
|
let showingE2EAlert = false;
|
|
|
|
class E2E {
|
|
constructor() {
|
|
this.started = false;
|
|
this.enabled = new ReactiveVar(false);
|
|
this._ready = new ReactiveVar(false);
|
|
this.instancesByRoomId = {};
|
|
this.readyPromise = new Deferred();
|
|
this.readyPromise.then(() => {
|
|
this._ready.set(true);
|
|
});
|
|
}
|
|
|
|
isEnabled() {
|
|
return this.enabled.get();
|
|
}
|
|
|
|
isReady() {
|
|
return this.enabled.get() && this._ready.get();
|
|
}
|
|
|
|
async ready() {
|
|
return this.readyPromise;
|
|
}
|
|
|
|
async getInstanceByRoomId(roomId) {
|
|
if (!this.enabled.get()) {
|
|
return;
|
|
}
|
|
|
|
const room = Rooms.findOne({
|
|
_id: roomId,
|
|
});
|
|
|
|
if (!room) {
|
|
return;
|
|
}
|
|
|
|
if (room.encrypted !== true) {
|
|
return;
|
|
}
|
|
|
|
if (!this.instancesByRoomId[roomId]) {
|
|
const subscription = Subscriptions.findOne({
|
|
rid: roomId,
|
|
});
|
|
|
|
if (!subscription || (subscription.t !== 'd' && subscription.t !== 'p')) {
|
|
return;
|
|
}
|
|
|
|
this.instancesByRoomId[roomId] = new E2ERoom(Meteor.userId(), roomId, subscription.t);
|
|
}
|
|
|
|
const e2eRoom = this.instancesByRoomId[roomId];
|
|
|
|
await this.ready();
|
|
|
|
if (e2eRoom) {
|
|
await e2eRoom.handshake();
|
|
return e2eRoom;
|
|
}
|
|
}
|
|
|
|
async startClient() {
|
|
if (this.started) {
|
|
return;
|
|
}
|
|
|
|
this.started = true;
|
|
let public_key = localStorage.getItem('public_key');
|
|
let private_key = localStorage.getItem('private_key');
|
|
|
|
await this.loadKeysFromDB();
|
|
|
|
if (!public_key && this.db_public_key) {
|
|
public_key = this.db_public_key;
|
|
}
|
|
|
|
if (!private_key && this.db_private_key) {
|
|
try {
|
|
private_key = await this.decodePrivateKey(this.db_private_key);
|
|
} catch (error) {
|
|
this.started = false;
|
|
failedToDecodeKey = true;
|
|
this.openAlert({
|
|
title: TAPi18n.__('Wasn\'t possible to decode your encryption key to be imported.'),
|
|
html: '<div>Your encryption password seems wrong. Click here to try again.</div>',
|
|
modifiers: ['large', 'danger'],
|
|
closable: true,
|
|
icon: 'key',
|
|
action: () => {
|
|
this.startClient();
|
|
this.closeAlert();
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (public_key && private_key) {
|
|
await this.loadKeys({ public_key, private_key });
|
|
} else {
|
|
await this.createAndLoadKeys();
|
|
}
|
|
|
|
// TODO: Split in 2 methods to persist keys
|
|
if (!this.db_public_key || !this.db_private_key) {
|
|
await call('e2e.setUserPublicAndPivateKeys', {
|
|
public_key: localStorage.getItem('public_key'),
|
|
private_key: await this.encodePrivateKey(localStorage.getItem('private_key'), this.createRandomPassword()),
|
|
});
|
|
}
|
|
|
|
const randomPassword = localStorage.getItem('e2e.randomPassword');
|
|
if (randomPassword) {
|
|
const passwordRevealText = TAPi18n.__('E2E_password_reveal_text', {
|
|
postProcess: 'sprintf',
|
|
sprintf: [randomPassword],
|
|
});
|
|
|
|
this.openAlert({
|
|
title: TAPi18n.__('Save_your_encryption_password'),
|
|
html: TAPi18n.__('Click_here_to_view_and_copy_your_password'),
|
|
modifiers: ['large'],
|
|
closable: false,
|
|
icon: 'key',
|
|
action: () => {
|
|
modal.open({
|
|
title: TAPi18n.__('Save_your_encryption_password'),
|
|
html: true,
|
|
text: `<div>${ passwordRevealText }</div>`,
|
|
showConfirmButton: true,
|
|
showCancelButton: true,
|
|
confirmButtonText: TAPi18n.__('I_saved_my_password_close_this_message'),
|
|
cancelButtonText: TAPi18n.__('I_ll_do_it_later'),
|
|
}, (confirm) => {
|
|
if (!confirm) {
|
|
return;
|
|
}
|
|
localStorage.removeItem('e2e.randomPassword');
|
|
this.closeAlert();
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
this.readyPromise.resolve();
|
|
|
|
this.setupListeners();
|
|
|
|
this.decryptPendingMessages();
|
|
this.decryptPendingSubscriptions();
|
|
}
|
|
|
|
async stopClient() {
|
|
console.log('E2E -> Stop Client');
|
|
// This flag is used to avoid closing unrelated alerts.
|
|
if (showingE2EAlert) {
|
|
alerts.close();
|
|
}
|
|
|
|
localStorage.removeItem('public_key');
|
|
localStorage.removeItem('private_key');
|
|
this.instancesByRoomId = {};
|
|
this.privateKey = null;
|
|
this.enabled.set(false);
|
|
this._ready.set(false);
|
|
this.started = false;
|
|
|
|
this.readyPromise = new Deferred();
|
|
this.readyPromise.then(() => {
|
|
this._ready.set(true);
|
|
});
|
|
}
|
|
|
|
setupListeners() {
|
|
Notifications.onUser('e2ekeyRequest', async(roomId, keyId) => {
|
|
const e2eRoom = await this.getInstanceByRoomId(roomId);
|
|
if (!e2eRoom) {
|
|
return;
|
|
}
|
|
|
|
e2eRoom.provideKeyToUser(keyId);
|
|
});
|
|
|
|
Subscriptions.after.update((userId, doc) => {
|
|
this.decryptSubscription(doc);
|
|
});
|
|
|
|
Subscriptions.after.insert((userId, doc) => {
|
|
this.decryptSubscription(doc);
|
|
});
|
|
|
|
Messages.after.update((userId, doc) => {
|
|
this.decryptMessage(doc);
|
|
});
|
|
|
|
Messages.after.insert((userId, doc) => {
|
|
this.decryptMessage(doc);
|
|
});
|
|
}
|
|
|
|
async changePassword(newPassword) {
|
|
await call('e2e.setUserPublicAndPivateKeys', {
|
|
public_key: localStorage.getItem('public_key'),
|
|
private_key: await this.encodePrivateKey(localStorage.getItem('private_key'), newPassword),
|
|
});
|
|
|
|
if (localStorage.getItem('e2e.randomPassword')) {
|
|
localStorage.setItem('e2e.randomPassword', newPassword);
|
|
}
|
|
}
|
|
|
|
async loadKeysFromDB() {
|
|
try {
|
|
const { public_key, private_key } = await call('e2e.fetchMyKeys');
|
|
|
|
this.db_public_key = public_key;
|
|
this.db_private_key = private_key;
|
|
} catch (error) {
|
|
return console.error('E2E -> Error fetching RSA keys: ', error);
|
|
}
|
|
}
|
|
|
|
async loadKeys({ public_key, private_key }) {
|
|
localStorage.setItem('public_key', public_key);
|
|
|
|
try {
|
|
this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']);
|
|
|
|
localStorage.setItem('private_key', private_key);
|
|
} catch (error) {
|
|
return console.error('E2E -> Error importing private key: ', error);
|
|
}
|
|
}
|
|
|
|
async createAndLoadKeys() {
|
|
// Could not obtain public-private keypair from server.
|
|
let key;
|
|
try {
|
|
key = await generateRSAKey();
|
|
this.privateKey = key.privateKey;
|
|
} catch (error) {
|
|
return console.error('E2E -> Error generating key: ', error);
|
|
}
|
|
|
|
try {
|
|
const publicKey = await exportJWKKey(key.publicKey);
|
|
|
|
localStorage.setItem('public_key', JSON.stringify(publicKey));
|
|
} catch (error) {
|
|
return console.error('E2E -> Error exporting public key: ', error);
|
|
}
|
|
|
|
try {
|
|
const privateKey = await exportJWKKey(key.privateKey);
|
|
|
|
localStorage.setItem('private_key', JSON.stringify(privateKey));
|
|
} catch (error) {
|
|
return console.error('E2E -> Error exporting private key: ', error);
|
|
}
|
|
|
|
this.requestSubscriptionKeys();
|
|
}
|
|
|
|
async requestSubscriptionKeys() {
|
|
call('e2e.requestSubscriptionKeys');
|
|
}
|
|
|
|
createRandomPassword() {
|
|
const randomPassword = `${ Random.id(3) }-${ Random.id(3) }-${ Random.id(3) }`.toLowerCase();
|
|
localStorage.setItem('e2e.randomPassword', randomPassword);
|
|
return randomPassword;
|
|
}
|
|
|
|
async encodePrivateKey(private_key, password) {
|
|
const masterKey = await this.getMasterKey(password);
|
|
|
|
const vector = crypto.getRandomValues(new Uint8Array(16));
|
|
try {
|
|
const encodedPrivateKey = await encryptAES(vector, masterKey, toArrayBuffer(private_key));
|
|
|
|
return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey));
|
|
} catch (error) {
|
|
return console.error('E2E -> Error encrypting encodedPrivateKey: ', error);
|
|
}
|
|
}
|
|
|
|
async getMasterKey(password) {
|
|
if (password == null) {
|
|
alert('You should provide a password');
|
|
}
|
|
|
|
// First, create a PBKDF2 "key" containing the password
|
|
let baseKey;
|
|
try {
|
|
baseKey = await importRawKey(toArrayBuffer(password));
|
|
} catch (error) {
|
|
return console.error('E2E -> Error creating a key based on user password: ', error);
|
|
}
|
|
|
|
// Derive a key from the password
|
|
try {
|
|
return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey);
|
|
} catch (error) {
|
|
return console.error('E2E -> Error deriving baseKey: ', error);
|
|
}
|
|
}
|
|
|
|
async requestPassword() {
|
|
return new Promise((resolve) => {
|
|
let showAlert;
|
|
|
|
const showModal = () => {
|
|
modal.open({
|
|
title: TAPi18n.__('Enter_E2E_password_to_decode_your_key'),
|
|
type: 'input',
|
|
inputType: 'password',
|
|
html: true,
|
|
text: `<div>${ TAPi18n.__('E2E_password_request_text') }</div>`,
|
|
showConfirmButton: true,
|
|
showCancelButton: true,
|
|
confirmButtonText: TAPi18n.__('Decode_Key'),
|
|
cancelButtonText: TAPi18n.__('I_ll_do_it_later'),
|
|
}, (password) => {
|
|
if (password) {
|
|
this.closeAlert();
|
|
resolve(password);
|
|
}
|
|
}, () => {
|
|
failedToDecodeKey = false;
|
|
showAlert();
|
|
});
|
|
};
|
|
|
|
showAlert = () => {
|
|
this.openAlert({
|
|
title: TAPi18n.__('Enter_your_E2E_password'),
|
|
html: TAPi18n.__('Click_here_to_enter_your_encryption_password'),
|
|
modifiers: ['large'],
|
|
closable: false,
|
|
icon: 'key',
|
|
action() {
|
|
showModal();
|
|
},
|
|
});
|
|
};
|
|
|
|
if (failedToDecodeKey) {
|
|
showModal();
|
|
} else {
|
|
showAlert();
|
|
}
|
|
});
|
|
}
|
|
|
|
async decodePrivateKey(private_key) {
|
|
const password = await this.requestPassword();
|
|
|
|
const masterKey = await this.getMasterKey(password);
|
|
|
|
const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(private_key));
|
|
|
|
try {
|
|
const privKey = await decryptAES(vector, masterKey, cipherText);
|
|
return toString(privKey);
|
|
} catch (error) {
|
|
throw new Error('E2E -> Error decrypting private key');
|
|
}
|
|
}
|
|
|
|
async decryptMessage(message) {
|
|
if (!this.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (message.t !== 'e2e' || message.e2e === 'done') {
|
|
return;
|
|
}
|
|
|
|
const e2eRoom = await this.getInstanceByRoomId(message.rid);
|
|
|
|
if (!e2eRoom) {
|
|
return;
|
|
}
|
|
|
|
const data = await e2eRoom.decrypt(message.msg);
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
Messages.direct.update({ _id: message._id }, {
|
|
$set: {
|
|
msg: data.text,
|
|
e2e: 'done',
|
|
},
|
|
});
|
|
}
|
|
|
|
async decryptPendingMessages() {
|
|
if (!this.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
return await Messages.find({ t: 'e2e', e2e: 'pending' }).forEach(async(item) => {
|
|
await this.decryptMessage(item);
|
|
});
|
|
}
|
|
|
|
async decryptSubscription(subscription) {
|
|
if (!this.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (!subscription.lastMessage || subscription.lastMessage.t !== 'e2e' || subscription.lastMessage.e2e === 'done') {
|
|
return;
|
|
}
|
|
|
|
const e2eRoom = await this.getInstanceByRoomId(subscription.rid);
|
|
|
|
if (!e2eRoom) {
|
|
return;
|
|
}
|
|
|
|
const data = await e2eRoom.decrypt(subscription.lastMessage.msg);
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
Subscriptions.direct.update({
|
|
_id: subscription._id,
|
|
}, {
|
|
$set: {
|
|
'lastMessage.msg': data.text,
|
|
'lastMessage.e2e': 'done',
|
|
},
|
|
});
|
|
}
|
|
|
|
async decryptPendingSubscriptions() {
|
|
Subscriptions.find({
|
|
'lastMessage.t': 'e2e',
|
|
'lastMessage.e2e': {
|
|
$ne: 'done',
|
|
},
|
|
}).forEach(this.decryptSubscription.bind(this));
|
|
}
|
|
|
|
openAlert(config) {
|
|
showingE2EAlert = true;
|
|
alerts.open(config);
|
|
}
|
|
|
|
closeAlert() {
|
|
showingE2EAlert = false;
|
|
alerts.close();
|
|
}
|
|
}
|
|
|
|
export const e2e = new E2E();
|
|
|
|
Meteor.startup(function() {
|
|
Tracker.autorun(function() {
|
|
if (Meteor.userId()) {
|
|
const adminEmbedded = Layout.isEmbedded() && FlowRouter.current().path.startsWith('/admin');
|
|
|
|
if (!adminEmbedded && settings.get('E2E_Enable') && window.crypto) {
|
|
e2e.startClient();
|
|
e2e.enabled.set(true);
|
|
} else {
|
|
e2e.enabled.set(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Encrypt messages before sending
|
|
promises.add('onClientBeforeSendMessage', async function(message) {
|
|
if (!message.rid) {
|
|
return Promise.resolve(message);
|
|
}
|
|
|
|
const e2eRoom = await e2e.getInstanceByRoomId(message.rid);
|
|
if (!e2eRoom) {
|
|
return Promise.resolve(message);
|
|
}
|
|
|
|
// Should encrypt this message.
|
|
return e2eRoom
|
|
.encrypt(message)
|
|
.then((msg) => {
|
|
message.msg = msg;
|
|
message.t = 'e2e';
|
|
message.e2e = 'pending';
|
|
return message;
|
|
});
|
|
}, promises.priority.HIGH);
|
|
});
|
|
|