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-12 17:10:22 +02:00
|
|
|
import { Settings, Cache } from 'modules';
|
2018-08-09 09:56:44 +02:00
|
|
|
import BuiltinModule from './BuiltinModule';
|
2018-08-12 14:39:00 +02:00
|
|
|
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi, Security } from 'modules';
|
2018-08-10 14:08:21 +02:00
|
|
|
import { VueInjector, Reflection } from 'ui';
|
2018-08-11 03:58:55 +02:00
|
|
|
import { ClientLogger as Logger } 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';
|
2018-08-11 03:58:55 +02:00
|
|
|
import E2EEMessageButton from './E2EEMessageButton.vue';
|
2018-08-09 09:56:44 +02:00
|
|
|
|
2018-08-11 07:15:49 +02:00
|
|
|
const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g");
|
|
|
|
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g");
|
|
|
|
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`);
|
|
|
|
|
2018-08-13 09:45:52 +02:00
|
|
|
const TEMP_KEY = 'temporarymasterkey';
|
|
|
|
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-10 16:01:07 +02:00
|
|
|
constructor() {
|
|
|
|
super();
|
2018-08-11 03:43:02 +02:00
|
|
|
this.encryptNewMessages = true;
|
2018-08-09 09:56:44 +02:00
|
|
|
}
|
|
|
|
|
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 16:01:07 +02:00
|
|
|
get settingPath() {
|
|
|
|
return ['security', 'default', 'e2ee'];
|
2018-08-10 15:22:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
get database() {
|
2018-08-11 03:43:02 +02:00
|
|
|
return Settings.getSetting('security', 'e2eedb', 'e2ekvps').value;
|
2018-08-10 15:22:30 +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);
|
|
|
|
}
|
|
|
|
|
2018-08-12 14:39:00 +02:00
|
|
|
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-12 14:39:00 +02:00
|
|
|
}
|
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-12 20:43:59 +02:00
|
|
|
}
|
|
|
|
|
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-10 14:08:21 +02:00
|
|
|
async enabled(e) {
|
2018-08-13 09:45:52 +02:00
|
|
|
seed = Security.randomBytes();
|
|
|
|
// TODO Input modal for key
|
2018-08-13 11:33:44 +02:00
|
|
|
this.master = Security.encrypt(seed, TEMP_KEY);
|
2018-08-11 07:15:49 +02:00
|
|
|
this.patchDispatcher();
|
2018-08-10 19:38:02 +02:00
|
|
|
this.patchMessageContent();
|
|
|
|
const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton');
|
2018-08-11 02:39:13 +02:00
|
|
|
const cta = await ReactComponents.getComponent('ChannelTextArea', { selector });
|
|
|
|
this.patchChannelTextArea(cta);
|
|
|
|
this.patchChannelTextAreaSubmit(cta);
|
|
|
|
cta.forceUpdateAll();
|
|
|
|
}
|
|
|
|
|
2018-08-11 07:15:49 +02:00
|
|
|
patchDispatcher() {
|
|
|
|
const Dispatcher = WebpackModules.getModuleByName('Dispatcher');
|
|
|
|
MonkeyPatch('BD:E2EE', Dispatcher).before('dispatch', (_, [event]) => {
|
|
|
|
if (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 = WebpackModules.getModuleByName('MessageParser');
|
|
|
|
const Permissions = WebpackModules.getModuleByName('GuildPermissions');
|
|
|
|
const DiscordConstants = WebpackModules.getModuleByName('DiscordConstants');
|
|
|
|
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-11 02:39:13 +02:00
|
|
|
async patchMessageContent() {
|
|
|
|
const selector = '.' + WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited');
|
|
|
|
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector });
|
2018-08-11 03:58:55 +02:00
|
|
|
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).before('render', this.beforeRenderMessageContent.bind(this));
|
|
|
|
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).after('render', this.renderMessageContent.bind(this));
|
2018-08-11 14:29:30 +02:00
|
|
|
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: '.' + WebpackModules.getClassName('imageWrapper') });
|
|
|
|
MonkeyPatch('BD:E2EE', ImageWrapper.component.prototype).before('render', this.beforeRenderImageWrapper.bind(this));
|
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
|
|
|
|
|
|
|
|
const Message = WebpackModules.getModuleByPrototypes(['isMentioned']);
|
|
|
|
const MessageParser = WebpackModules.getModuleByName('MessageParser');
|
2018-08-11 07:15:49 +02:00
|
|
|
const Permissions = WebpackModules.getModuleByName('GuildPermissions');
|
|
|
|
const DiscordConstants = WebpackModules.getModuleByName('DiscordConstants');
|
|
|
|
const currentChannel = DiscordApi.Channel.fromId(component.props.message.channel_id).discordObject;
|
|
|
|
|
|
|
|
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))
|
|
|
|
message.mentions = message.content.match(userMentionPattern).map(m => {return {id: m.replace(/[^0-9]/g, '')}});
|
|
|
|
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 03:58:55 +02:00
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2018-08-11 03:58:55 +02:00
|
|
|
renderMessageContent(component, args, retVal) {
|
|
|
|
if (!component.props.message.bd_encrypted) return;
|
2018-08-13 11:33:44 +02:00
|
|
|
try {
|
|
|
|
retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton));
|
|
|
|
} catch (err) {
|
|
|
|
Logger.err('E2EE', err.message);
|
|
|
|
}
|
2018-08-11 03:58:55 +02:00
|
|
|
}
|
|
|
|
|
2018-08-11 14:29:30 +02:00
|
|
|
beforeRenderImageWrapper(component, args, retVal) {
|
|
|
|
if (!component.props || !component.props.src) return;
|
|
|
|
if (component.props.decrypting) return;
|
2018-08-12 14:39:00 +02:00
|
|
|
component.props.decrypting = true;
|
2018-08-11 14:29:30 +02:00
|
|
|
|
2018-08-12 14:39:00 +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;
|
2018-08-12 14:39:00 +02:00
|
|
|
component.props.className = 'bd-encryptedImage';
|
2018-08-11 14:29:30 +02:00
|
|
|
|
2018-08-12 14:39:00 +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);
|
2018-08-12 14:39:00 +02:00
|
|
|
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;
|
|
|
|
}
|
2018-08-12 14:39:00 +02:00
|
|
|
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]);
|
2018-08-12 14:39:00 +02:00
|
|
|
component.props.className = 'bd-decryptedImage';
|
2018-08-12 19:57:02 +02:00
|
|
|
component.props.src = component.props.original = 'data:;base64,' + decrypt;
|
2018-08-12 14:39:00 +02:00
|
|
|
} catch (err) { return } finally { component.props.readyState = 'READY' }
|
2018-08-11 14:29:30 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-08-12 14:39:00 +02:00
|
|
|
component.props.readyState = 'LOADING';
|
|
|
|
Logger.info('E2EE', 'Decrypting image: ' + src);
|
|
|
|
request.get(src, { encoding: 'binary' }).then(res => {
|
2018-08-12 20:43:59 +02:00
|
|
|
(async () => {
|
|
|
|
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 });
|
2018-08-12 21:05:44 +02:00
|
|
|
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();
|
|
|
|
})();
|
2018-08-11 14:29:30 +02:00
|
|
|
}).catch(err => {
|
|
|
|
console.log('request error', err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-08-11 02:39:13 +02:00
|
|
|
patchChannelTextArea(cta) {
|
|
|
|
MonkeyPatch('BD:E2EE', cta.component.prototype).after('render', this.renderChannelTextArea);
|
|
|
|
}
|
|
|
|
|
|
|
|
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'));
|
2018-08-11 03:58:55 +02:00
|
|
|
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) {
|
|
|
|
MonkeyPatch('BD:E2EE', cta.component.prototype).before('handleSubmit', this.handleChannelTextAreaSubmit.bind(this));
|
|
|
|
}
|
|
|
|
|
|
|
|
handleChannelTextAreaSubmit(component, args, retVal) {
|
2018-08-10 15:22:30 +02:00
|
|
|
const key = this.getKey(DiscordApi.currentChannel.id);
|
2018-08-11 03:43:02 +02:00
|
|
|
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-10 19:38:02 +02:00
|
|
|
async disabled(e) {
|
2018-08-10 14:08:21 +02:00
|
|
|
for (const patch of Patcher.getPatchesByCaller('BD:E2EE')) patch.unpatch();
|
2018-08-10 19:38:02 +02:00
|
|
|
const ctaComponent = await ReactComponents.getComponent('ChannelTextArea');
|
|
|
|
ctaComponent.forceUpdateAll();
|
2018-08-09 09:56:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|