BetterDiscordApp-v2/client/src/builtin/E2EE/E2EE.js

397 lines
17 KiB
JavaScript
Raw Normal View History

2018-08-09 09:56:44 +02:00
/**
* BetterDiscord E2EE Module
* Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
* All rights reserved.
* https://betterdiscord.net
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
2018-08-14 14:30:43 +02:00
import { Settings, Cache, Events } from 'modules';
import BuiltinModule from '../BuiltinModule';
2019-03-12 17:35:12 +01:00
import { Reflection, ReactComponents, DiscordApi, Security } from 'modules';
import { VueInjector, Modals, Toasts } from 'ui';
import { ClientLogger as Logger, ClientIPC } from 'common';
2018-08-11 14:29:30 +02:00
import { request } from 'vendor';
import { Utils } from 'common';
2018-08-10 14:30:24 +02:00
import E2EEComponent from './E2EEComponent.vue';
import E2EEMessageButton from './E2EEMessageButton.vue';
2018-08-13 19:35:16 +02:00
import nodecrypto from 'node-crypto';
2018-08-09 09:56:44 +02:00
2018-08-15 08:01:47 +02:00
const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, 'g');
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, 'g');
2018-08-11 07:15:49 +02:00
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`);
2018-08-14 09:40:33 +02:00
const START_DATE = new Date();
2018-08-13 09:45:52 +02:00
const TEMP_KEY = 'temporarymasterkey';
2018-08-14 10:16:39 +02:00
const ECDH_STORAGE = {};
2018-08-13 09:45:52 +02:00
let seed;
2018-08-10 16:01:07 +02:00
2018-08-09 09:56:44 +02:00
export default new class E2EE extends BuiltinModule {
2018-08-25 18:19:19 +02:00
/* Getters */
get moduleName() { return 'E2EE' }
get settingPath() { return ['security', 'default', 'e2ee'] }
get database() { return Settings.getSetting('security', 'e2eedb', 'e2ekvps').value }
2018-08-10 16:01:07 +02:00
constructor() {
super();
this.encryptNewMessages = true;
2018-08-14 09:40:33 +02:00
this.ecdhDate = START_DATE;
2018-08-14 14:30:43 +02:00
this.handlePublicKey = this.handlePublicKey.bind(this);
this.fetchMasterKey = this.fetchMasterKey.bind(this);
2018-08-14 14:30:43 +02:00
}
async enabled(e) {
await this.fetchMasterKey();
Events.on('discord:MESSAGE_CREATE', this.handlePublicKey);
2018-08-25 18:19:19 +02:00
Settings.getSetting('security', 'default', 'use-keytar').on('setting-updated', this.fetchMasterKey);
2018-08-14 14:30:43 +02:00
}
async disabled(e) {
Settings.getSetting('security', 'default', 'use-keytar').off('setting-updated', this.fetchMasterKey);
2018-08-14 14:30:43 +02:00
Events.off('discord:MESSAGE_CREATE', this.handlePublicKey);
const ctaComponent = await ReactComponents.getComponent('ChannelTextArea');
ctaComponent.forceUpdateAll();
2018-08-09 09:56:44 +02:00
}
2018-08-25 18:19:19 +02:00
/* Methods */
async fetchMasterKey() {
try {
if (Settings.get('security', 'default', 'use-keytar')) {
const master = await ClientIPC.getPassword('betterdiscord', 'master');
if (master) return this.setMaster(master);
if (Settings.getSetting('security', 'e2eedb', 'e2ekvps').items.length) {
// Ask the user for their current password to save to the system keychain
const currentMaster = await Modals.input('Save to System Keychain', 'Master Password', true).promise;
await ClientIPC.setPassword('betterdiscord', 'master', currentMaster);
return this.setMaster(currentMaster);
}
// Generate a new master password and save it to the system keychain
const newMaster = Security.randomBytes();
await ClientIPC.setPassword('betterdiscord', 'master', newMaster);
return this.setMaster(newMaster);
}
const newMaster = await Modals.input('Open Database', 'Master Password', true).promise;
return this.setMaster(newMaster);
} catch (err) {
Settings.getSetting(...this.settingPath).value = false;
Toasts.error('Invalid master password! E2EE Disabled');
Logger.err('E2EE', ['Error fetching master password', err]);
}
}
2018-08-10 16:02:41 +02:00
setMaster(key) {
2018-08-12 21:11:41 +02:00
seed = Security.randomBytes();
2018-08-13 11:33:44 +02:00
const newMaster = Security.encrypt(seed, key);
2018-08-10 16:04:09 +02:00
// TODO re-encrypt everything with new master
return (this.master = newMaster);
2018-08-10 16:02:41 +02:00
}
2018-08-10 20:17:52 +02:00
encrypt(key, content, prefix = '') {
2018-08-13 11:33:44 +02:00
if (!key) {
// Encrypt something with master
return Security.encrypt(Security.decrypt(seed, this.master), content);
}
if (!content) {
// Get key for current channel and encrypt
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return 'nokey';
2018-08-13 11:33:44 +02:00
return Security.encrypt(Security.decrypt(seed, [this.master, haveKey]), key);
}
2018-08-13 11:33:44 +02:00
return prefix + Security.encrypt(key, content);
2018-08-10 15:22:30 +02:00
}
2018-08-10 20:17:52 +02:00
decrypt(key, content, prefix = '') {
2018-08-13 11:33:44 +02:00
return Security.decrypt(key, content, prefix);
2018-08-10 15:22:30 +02:00
}
2018-08-12 20:43:59 +02:00
async createHmac(data) {
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return null;
2018-08-13 11:56:41 +02:00
return Security.createHmac(Security.decrypt(seed, [this.master, haveKey]), data);
2018-08-10 15:22:30 +02:00
}
getKey(channelId) {
const haveKey = this.database.find(kvp => kvp.value.key === channelId);
if (!haveKey) return null;
return haveKey.value.value;
}
2018-08-12 01:21:08 +02:00
setKey(channelId, key) {
const items = Settings.getSetting('security', 'e2eedb', 'e2ekvps').items;
const index = items.findIndex(kvp => kvp.value.key === channelId);
if (index > -1) {
2018-08-25 15:50:48 +02:00
items[index].value = { key: channelId, value: key };
2018-08-15 08:01:47 +02:00
return;
2018-08-12 01:21:08 +02:00
}
Settings.getSetting('security', 'e2eedb', 'e2ekvps').addItem({ value: { key: channelId, value: key } });
}
createKeyExchange(dmChannelID) {
2018-08-14 10:16:39 +02:00
if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) return null;
ECDH_STORAGE[dmChannelID] = Security.createECDH();
2018-08-14 09:40:33 +02:00
setTimeout(() => {
2018-08-14 10:16:39 +02:00
if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) {
delete ECDH_STORAGE[dmChannelID];
2018-08-14 09:40:33 +02:00
Toasts.error('Key exchange expired!');
if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
this.preExchangeState = null;
2018-08-14 09:40:33 +02:00
}
}, 30000);
2018-08-14 10:16:39 +02:00
return Security.generateECDHKeys(ECDH_STORAGE[dmChannelID]);
}
publicKeyFor(dmChannelID) {
2018-08-14 10:16:39 +02:00
return Security.getECDHPublicKey(ECDH_STORAGE[dmChannelID]);
}
computeSecret(dmChannelID, otherKey) {
try {
2018-08-14 10:16:39 +02:00
const secret = Security.computeECDHSecret(ECDH_STORAGE[dmChannelID], otherKey);
delete ECDH_STORAGE[dmChannelID];
2018-08-14 10:02:29 +02:00
return Security.hash('sha384', secret, 'hex');
} catch (e) {
throw e;
}
}
2018-08-25 18:19:19 +02:00
/* Patches */
async applyPatches() {
if (this.patches.length) return;
const { Dispatcher } = Reflection.modules;
this.patch(Dispatcher, 'dispatch', this.dispatcherPatch, 'before');
this.patchMessageContent();
const ChannelTextArea = await ReactComponents.getComponent('ChannelTextArea');
2018-08-25 18:19:19 +02:00
this.patchChannelTextArea(ChannelTextArea);
this.patchChannelTextAreaSubmit(ChannelTextArea);
ChannelTextArea.forceUpdateAll();
}
dispatcherPatch(_, [event]) {
if (!event || event.type !== 'MESSAGE_CREATE') return;
const key = this.getKey(event.message.channel_id);
if (!key) return; // We don't have a key for this channel
if (typeof event.message.content !== 'string') return; // Ignore any non string content
if (!event.message.content.startsWith('$:')) return; // Not an encrypted string
let decrypt;
try {
decrypt = this.decrypt(this.decrypt(this.decrypt(seed, this.master), key), event.message.content);
} catch (err) { return } // Ignore errors such as non empty
const { MessageParser, Permissions, DiscordConstants } = Reflection.modules;
const currentChannel = DiscordApi.Channel.fromId(event.message.channel_id).discordObject;
// Create a generic message object to parse mentions with
const parsed = MessageParser.parse(currentChannel, decrypt).content;
if (userMentionPattern.test(parsed))
event.message.mentions = parsed.match(userMentionPattern).map(m => { return { id: m.replace(/[^0-9]/g, '') } });
if (roleMentionPattern.test(parsed))
event.message.mention_roles = parsed.match(roleMentionPattern).map(m => m.replace(/[^0-9]/g, ''));
if (everyoneMentionPattern.test(parsed))
event.message.mention_everyone = Permissions.can(DiscordConstants.Permissions.MENTION_EVERYONE, currentChannel);
}
2018-08-14 12:29:19 +02:00
// TODO Received exchange should also expire if not accepted in time
2018-08-14 14:30:43 +02:00
async handlePublicKey(e) {
2018-08-14 14:43:55 +02:00
if (!DiscordApi.currentChannel) return;
2018-08-14 14:30:43 +02:00
if (DiscordApi.currentChannel.type !== 'DM') return;
const { id, content, author, channelId } = e.args;
if (author.id === DiscordApi.currentUser.id || channelId !== DiscordApi.currentChannel.id) return;
const [tagstart, begin, key, end, tagend] = content.split('\n');
if (begin !== '-----BEGIN PUBLIC KEY-----' || end !== '-----END PUBLIC KEY-----') return;
2018-08-14 09:40:33 +02:00
try {
2018-08-14 14:30:43 +02:00
await Modals.confirm('Key Exchange', `Key exchange request from: ${author.username}#${author.discriminator}`, 'Accept', 'Reject').promise;
2018-08-14 09:40:33 +02:00
// We already sent our key
2018-08-14 10:16:39 +02:00
if (!ECDH_STORAGE.hasOwnProperty(channelId)) {
2018-08-14 09:40:33 +02:00
const publicKeyMessage = `\`\`\`\n-----BEGIN PUBLIC KEY-----\n${this.createKeyExchange(channelId)}\n-----END PUBLIC KEY-----\n\`\`\``;
if (this.encryptNewMessages) this.encryptNewMessages = false;
Reflection.modules.DraftActions.saveDraft(channelId, publicKeyMessage);
2018-08-14 09:40:33 +02:00
}
const secret = this.computeSecret(channelId, key);
2018-08-14 09:41:22 +02:00
this.setKey(channelId, secret);
2018-08-14 09:40:33 +02:00
Toasts.success('Key exchange complete!');
2018-08-14 11:51:57 +02:00
if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
this.preExchangeState = null;
2018-08-14 09:40:33 +02:00
} catch (err) {
2018-08-14 14:30:43 +02:00
console.log(err);
2018-08-14 09:40:33 +02:00
return;
}
2018-08-11 02:39:13 +02:00
}
async patchMessageContent() {
const MessageContent = await ReactComponents.getComponent('MessageContent');
2018-08-25 18:19:19 +02:00
this.patch(MessageContent.component.prototype, 'render', this.beforeRenderMessageContent, 'before');
2019-03-12 17:35:12 +01:00
this.childPatch(MessageContent.component.prototype, 'render', ['props', 'children'], this.afterRenderMessageContent);
2019-03-10 18:45:20 +01:00
MessageContent.forceUpdateAll();
2018-08-25 18:19:19 +02:00
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper');
2018-08-25 18:19:19 +02:00
this.patch(ImageWrapper.component.prototype, 'render', this.beforeRenderImageWrapper, 'before');
2019-03-10 18:45:20 +01:00
ImageWrapper.forceUpdateAll();
2018-08-09 09:56:44 +02:00
}
2018-08-11 07:15:49 +02:00
beforeRenderMessageContent(component) {
if (!component.props || !component.props.message) return;
const key = this.getKey(component.props.message.channel_id);
2018-08-11 02:39:13 +02:00
if (!key) return; // We don't have a key for this channel
2018-08-25 15:50:48 +02:00
const Message = Reflection.module.byPrototypes('isMentioned');
const { MessageParser, Permissions, DiscordConstants } = Reflection.modules;
2018-08-11 07:15:49 +02:00
const currentChannel = DiscordApi.Channel.fromId(component.props.message.channel_id).discordObject;
2018-08-12 01:21:08 +02:00
2018-08-11 07:15:49 +02:00
if (typeof component.props.message.content !== 'string') return; // Ignore any non string content
if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string
2018-08-11 02:39:13 +02:00
let decrypt;
try {
2018-08-13 11:33:44 +02:00
decrypt = Security.decrypt(seed, [this.master, key, component.props.message.content]);
2018-08-11 02:39:13 +02:00
} catch (err) { return } // Ignore errors such as non empty
2018-08-11 07:15:49 +02:00
component.props.message.bd_encrypted = true; // signal as encrypted
// Create a generic message object to parse mentions with
const message = MessageParser.createMessage(currentChannel.id, MessageParser.parse(currentChannel, decrypt).content);
if (userMentionPattern.test(message.content))
2018-08-25 15:50:48 +02:00
message.mentions = message.content.match(userMentionPattern).map(m => { return { id: m.replace(/[^0-9]/g, '') } });
2018-08-11 07:15:49 +02:00
if (roleMentionPattern.test(message.content))
message.mention_roles = message.content.match(roleMentionPattern).map(m => m.replace(/[^0-9]/g, ''));
if (everyoneMentionPattern.test(message.content))
message.mention_everyone = Permissions.can(DiscordConstants.Permissions.MENTION_EVERYONE, currentChannel);
2018-08-11 02:39:13 +02:00
// Create a new message to parse it properly
2018-08-11 07:15:49 +02:00
const create = Message.create(message);
2018-08-11 02:39:13 +02:00
if (!create.content || !create.contentParsed) return;
2018-08-11 07:15:49 +02:00
component.props.message.mentions = create.mentions;
component.props.message.mentionRoles = create.mentionRoles;
component.props.message.mentionEveryone = create.mentionEveryone;
component.props.message.mentioned = create.mentioned;
2018-08-11 02:39:13 +02:00
component.props.message.content = create.content;
component.props.message.contentParsed = create.contentParsed;
2018-08-10 19:38:02 +02:00
}
2019-03-12 17:35:12 +01:00
afterRenderMessageContent(component, _childrenObject, args, retVal) {
if (!component.props.message.bd_encrypted) return;
2019-03-12 17:35:12 +01:00
const { className } = Reflection.resolve('buttonContainer', 'avatar', 'username');
const buttonContainer = Utils.findInReactTree(retVal, m => m && m.className && m.className.indexOf(className) !== -1);
if (!buttonContainer) return;
const buttons = buttonContainer.children.props.children;
2018-08-13 22:38:13 +02:00
if (!buttons) return;
2019-03-12 17:35:12 +01:00
2018-08-13 11:33:44 +02:00
try {
2018-08-13 22:38:13 +02:00
buttons.unshift(VueInjector.createReactElement(E2EEMessageButton));
2018-08-13 11:33:44 +02:00
} catch (err) {
Logger.err('E2EE', err.message);
}
}
2018-08-11 14:29:30 +02:00
beforeRenderImageWrapper(component, args, retVal) {
if (!component.props || !component.props.src) return;
if (component.props.decrypting) return;
component.props.decrypting = true;
2018-08-11 14:29:30 +02:00
const src = component.props.original || component.props.src.split('?')[0];
2018-08-11 14:29:30 +02:00
if (!src.includes('bde2ee')) return;
component.props.className = 'bd-encryptedImage';
2018-08-11 14:29:30 +02:00
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return;
2018-08-12 17:10:22 +02:00
const cached = Cache.find('e2ee:images', item => item.src === src);
if (cached) {
2018-08-12 21:09:34 +02:00
if (cached.invalidKey) { // TODO If key has changed we should recheck all with invalid key
2018-08-12 20:56:57 +02:00
component.props.className = 'bd-encryptedImage bd-encryptedImageBadKey';
2018-08-12 20:46:59 +02:00
component.props.readyState = 'READY';
return;
}
Logger.info('E2EE', 'Returning encrypted image from cache');
try {
2018-08-13 11:33:44 +02:00
const decrypt = Security.decrypt(seed, [this.master, haveKey, cached.image]);
component.props.className = 'bd-decryptedImage';
2018-08-15 08:01:47 +02:00
component.props.src = component.props.original = `data:;base64,${decrypt}`;
} catch (err) { return } finally { component.props.readyState = 'READY' }
2018-08-11 14:29:30 +02:00
return;
}
component.props.readyState = 'LOADING';
2018-08-15 08:01:47 +02:00
Logger.info('E2EE', `Decrypting image: ${src}`);
(async () => {
try {
const res = await request.get(src, { encoding: 'binary' });
2018-08-12 20:43:59 +02:00
const arr = new Uint8Array(new ArrayBuffer(res.length));
for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i);
const aobindex = Utils.aobscan(arr, [73, 69, 78, 68]) + 8;
const sliced = arr.slice(aobindex);
const image = new TextDecoder().decode(sliced);
const hmac = image.slice(-64);
const data = image.slice(0, -64);
const validateHmac = await this.createHmac(data);
if (hmac !== validateHmac) {
2018-08-12 20:46:59 +02:00
Cache.push('e2ee:images', { src, invalidKey: true });
if (component && component.props) {
component.props.decrypting = false;
component.forceUpdate();
}
2018-08-12 20:43:59 +02:00
return;
}
Cache.push('e2ee:images', { src, image: data });
if (!component || !component.props) {
Logger.warn('E2EE', 'Component seems to be gone');
return;
}
component.props.decrypting = false;
component.forceUpdate();
} catch (err) {
console.log('request error', err);
}
})();
}
2018-08-11 02:39:13 +02:00
patchChannelTextArea(cta) {
2018-08-25 18:19:19 +02:00
this.patch(cta.component.prototype, 'render', this.renderChannelTextArea);
2018-08-11 02:39:13 +02:00
}
renderChannelTextArea(component, args, retVal) {
2018-08-10 14:08:21 +02:00
if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
const inner = retVal.props.children.find(child => child.props.className && child.props.className.includes('inner'));
inner.props.children.splice(0, 0, VueInjector.createReactElement(E2EEComponent));
2018-08-10 14:08:21 +02:00
}
2018-08-09 09:56:44 +02:00
2018-08-11 02:39:13 +02:00
patchChannelTextAreaSubmit(cta) {
2018-08-25 18:19:19 +02:00
this.patch(cta.component.prototype, 'handleSubmit', this.handleChannelTextAreaSubmit, 'before');
2018-08-11 02:39:13 +02:00
}
handleChannelTextAreaSubmit(component, args, retVal) {
2018-08-10 15:22:30 +02:00
const key = this.getKey(DiscordApi.currentChannel.id);
if (!this.encryptNewMessages || !key) return;
2018-08-13 11:33:44 +02:00
component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:');
2018-08-10 14:08:21 +02:00
}
2018-08-09 09:56:44 +02:00
}