diff --git a/client/src/builtin/E2EE.js b/client/src/builtin/E2EE.js index ac89fef0..3d31a73b 100644 --- a/client/src/builtin/E2EE.js +++ b/client/src/builtin/E2EE.js @@ -8,32 +8,33 @@ * LICENSE file in the root directory of this source tree. */ -import { Settings } from 'modules'; +import { Settings, Cache } from 'modules'; import BuiltinModule from './BuiltinModule'; -import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi } from 'modules'; +import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi, Security } from 'modules'; import { VueInjector, Reflection } from 'ui'; import { ClientLogger as Logger } from 'common'; +import { request } from 'vendor'; +import { Utils } from 'common'; import E2EEComponent from './E2EEComponent.vue'; import E2EEMessageButton from './E2EEMessageButton.vue'; -import aes256 from 'aes256'; const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g"); const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g"); const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`); -let seed = Math.random().toString(36).replace(/[^a-z]+/g, ''); +const TEMP_KEY = 'temporarymasterkey'; +let seed; export default new class E2EE extends BuiltinModule { constructor() { super(); - this.master = this.encrypt(seed, 'temporarymasterkey'); this.encryptNewMessages = true; } setMaster(key) { - seed = Math.random().toString(36).replace(/[^a-z]+/g, ''); - const newMaster = this.encrypt(seed, key); + seed = Security.randomBytes(); + const newMaster = Security.encrypt(seed, key); // TODO re-encrypt everything with new master return (this.master = newMaster); } @@ -47,11 +48,28 @@ export default new class E2EE extends BuiltinModule { } encrypt(key, content, prefix = '') { - return prefix + aes256.encrypt(key, content); + 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'; + return Security.encrypt(Security.decrypt(seed, [this.master, haveKey]), key); + } + return prefix + Security.encrypt(key, content); } decrypt(key, content, prefix = '') { - return aes256.decrypt(key, content.replace(prefix, '')); + return Security.decrypt(key, content, prefix); + } + + async createHmac(data) { + const haveKey = this.getKey(DiscordApi.currentChannel.id); + if (!haveKey) return null; + return Security.createHmac(Security.decrypt(seed, [this.master, haveKey]), data); } getKey(channelId) { @@ -61,6 +79,9 @@ export default new class E2EE extends BuiltinModule { } async enabled(e) { + seed = Security.randomBytes(); + // TODO Input modal for key + this.master = Security.encrypt(seed, TEMP_KEY); this.patchDispatcher(); this.patchMessageContent(); const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton'); @@ -107,6 +128,8 @@ export default new class E2EE extends BuiltinModule { const MessageContent = await ReactComponents.getComponent('MessageContent', { selector }); MonkeyPatch('BD:E2EE', MessageContent.component.prototype).before('render', this.beforeRenderMessageContent.bind(this)); MonkeyPatch('BD:E2EE', MessageContent.component.prototype).after('render', this.renderMessageContent.bind(this)); + const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: '.' + WebpackModules.getClassName('imageWrapper') }); + MonkeyPatch('BD:E2EE', ImageWrapper.component.prototype).before('render', this.beforeRenderImageWrapper.bind(this)); } beforeRenderMessageContent(component) { @@ -125,7 +148,7 @@ export default new class E2EE extends BuiltinModule { if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string let decrypt; try { - decrypt = this.decrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.message.content); + decrypt = Security.decrypt(seed, [this.master, key, component.props.message.content]); } catch (err) { return } // Ignore errors such as non empty component.props.message.bd_encrypted = true; // signal as encrypted @@ -154,10 +177,77 @@ export default new class E2EE extends BuiltinModule { renderMessageContent(component, args, retVal) { if (!component.props.message.bd_encrypted) return; + try { + retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton)); + } catch (err) { + Logger.err('E2EE', err.message); + } + } - retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton, { - message: component.props.message - })); + beforeRenderImageWrapper(component, args, retVal) { + if (!component.props || !component.props.src) return; + if (component.props.decrypting) return; + component.props.decrypting = true; + + const src = component.props.original || component.props.src.split('?')[0]; + if (!src.includes('bde2ee')) return; + component.props.className = 'bd-encryptedImage'; + + const haveKey = this.getKey(DiscordApi.currentChannel.id); + if (!haveKey) return; + + const cached = Cache.find('e2ee:images', item => item.src === src); + if (cached) { + if (cached.invalidKey) { // TODO If key has changed we should recheck all with invalid key + component.props.className = 'bd-encryptedImage bd-encryptedImageBadKey'; + component.props.readyState = 'READY'; + return; + } + Logger.info('E2EE', 'Returning encrypted image from cache'); + try { + const decrypt = Security.decrypt(seed, [this.master, haveKey, cached.image]); + component.props.className = 'bd-decryptedImage'; + component.props.src = component.props.original = 'data:;base64,' + decrypt; + } catch (err) { return } finally { component.props.readyState = 'READY' } + return; + } + + component.props.readyState = 'LOADING'; + Logger.info('E2EE', 'Decrypting image: ' + src); + request.get(src, { encoding: 'binary' }).then(res => { + (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) { + Cache.push('e2ee:images', { src, invalidKey: true }); + if (component && component.props) { + component.props.decrypting = false; + component.forceUpdate(); + } + 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); + }); } patchChannelTextArea(cta) { @@ -177,7 +267,7 @@ export default new class E2EE extends BuiltinModule { handleChannelTextAreaSubmit(component, args, retVal) { const key = this.getKey(DiscordApi.currentChannel.id); if (!this.encryptNewMessages || !key) return; - component.props.value = this.encrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.value, '$:'); + component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:'); } async disabled(e) { diff --git a/client/src/builtin/E2EEComponent.vue b/client/src/builtin/E2EEComponent.vue index cd8fdb38..62579c96 100644 --- a/client/src/builtin/E2EEComponent.vue +++ b/client/src/builtin/E2EEComponent.vue @@ -10,30 +10,43 @@ + + diff --git a/client/src/ui/components/common/MaterialIcon.js b/client/src/ui/components/common/MaterialIcon.js index 9840b262..f5d4b6b3 100644 --- a/client/src/ui/components/common/MaterialIcon.js +++ b/client/src/ui/components/common/MaterialIcon.js @@ -19,3 +19,4 @@ export { default as MiWarning } from './materialicons/Warning.vue'; export { default as MiSuccess } from './materialicons/Success.vue'; export { default as AccountCircle } from './materialicons/AccountCircle.vue'; export { default as MiLock } from './materialicons/Lock.vue'; +export { default as MiImagePlus } from './materialicons/ImagePlus.vue'; diff --git a/client/src/ui/components/common/materialicons/ImagePlus.vue b/client/src/ui/components/common/materialicons/ImagePlus.vue new file mode 100644 index 00000000..05f7640b --- /dev/null +++ b/client/src/ui/components/common/materialicons/ImagePlus.vue @@ -0,0 +1,27 @@ +/** + * BetterDiscord Image Plus Icon + * 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. + * + * Material Design Icons + * Copyright (c) 2014 Google + * Apache 2.0 LICENSE + * https://www.apache.org/licenses/LICENSE-2.0.txt +*/ + + + diff --git a/client/webpack.config.js b/client/webpack.config.js index 62a2c236..ca202b4d 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -38,7 +38,8 @@ module.exports = { process: 'require("process")', net: 'require("net")', request: 'require(require("path").join(require("electron").remote.app.getAppPath(), "node_modules", "request"))', - sparkplug: 'require("../../core/dist/sparkplug")' + sparkplug: 'require("../../core/dist/sparkplug")', + 'node-crypto': 'require("crypto")' }, resolve: { alias: { diff --git a/common/modules/utils.js b/common/modules/utils.js index c00ddc1a..198e233a 100644 --- a/common/modules/utils.js +++ b/common/modules/utils.js @@ -176,6 +176,69 @@ export class Utils { } while (!value); return value; } + + /** + * Finds the index of array of bytes in another array + * @param {Array} haystack The array to find aob in + * @param {Array} needle The aob to find + * @return {Number} aob index, -1 if not found + */ + static aobscan(haystack, needle) { + for (let h = 0; h < haystack.length - needle.length + 1; ++h) { + let found = true; + for (let n = 0; n < needle.length; ++n) { + if (needle[n] === null || + needle[n] === '??' || + haystack[h + n] === needle[n]) continue; + found = false; + break; + } + if (found) return h; + } + return -1; + } + + /** + * Convert buffer to base64 encoded string + * @param {any} buffer buffer to convert + * @returns {String} base64 encoded string from buffer + */ + static arrayBufferToBase64(buffer) { + let binary = ''; + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + static async getImageFromBuffer(buffer) { + if (!(buffer instanceof Blob)) buffer = new Blob([buffer]); + const reader = new FileReader(); + reader.readAsDataURL(buffer); + await new Promise(r => { + reader.onload = r + }); + const img = new Image(); + img.src = reader.result; + return await new Promise(resolve => { + img.onload = () => { + resolve(img); + } + }); + } + + static async canvasToArrayBuffer(canvas, mime = 'image/png') { + const reader = new FileReader(); + return new Promise(resolve => { + canvas.toBlob(blob => { + reader.addEventListener('loadend', () => { + resolve(reader.result); + }); + reader.readAsArrayBuffer(blob); + }, mime); + }); + } } export class FileUtils {