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 {