diff --git a/client/src/builtin/E2EE.js b/client/src/builtin/E2EE.js index 4f6c73fb..f0b8095c 100644 --- a/client/src/builtin/E2EE.js +++ b/client/src/builtin/E2EE.js @@ -8,33 +8,34 @@ * 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'; -import crypto from 'crypto'; +import nodecrypto from 'node-crypto'; 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); } @@ -49,11 +50,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) { @@ -73,6 +91,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'); @@ -119,6 +140,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) { @@ -137,7 +160,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 @@ -166,10 +189,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) { @@ -215,7 +305,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 7e8042c4..d767385d 100644 --- a/client/src/builtin/E2EEComponent.vue +++ b/client/src/builtin/E2EEComponent.vue @@ -10,19 +10,26 @@ + + diff --git a/client/src/ui/components/bd/setting/Setting.vue b/client/src/ui/components/bd/setting/Setting.vue index 107e6ee6..855ae6df 100644 --- a/client/src/ui/components/bd/setting/Setting.vue +++ b/client/src/ui/components/bd/setting/Setting.vue @@ -26,7 +26,8 @@ -
+ +
@@ -51,7 +52,8 @@ export default { props: [ - 'setting' + 'setting', + 'hide-divider' ], components: { BoolSetting, 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/src/ui/toasts.js b/client/src/ui/toasts.js index 1b572a54..75e3709c 100644 --- a/client/src/ui/toasts.js +++ b/client/src/ui/toasts.js @@ -8,6 +8,8 @@ * LICENSE file in the root directory of this source tree. */ +import { Settings } from 'modules'; + let toasts = 0; export default class Toasts { @@ -24,6 +26,8 @@ export default class Toasts { * @returns {Promise} This promise resolves when the toast is removed from the DOM. */ static async push(message, options = {}) { + if (!this.enabled) return; + const {type = 'basic', icon, additionalClasses, timeout = 3000} = options; const toast = {id: toasts++, message, type, icon, additionalClasses, closing: false}; this.stack.push(toast); @@ -72,4 +76,16 @@ export default class Toasts { return this._stack || (this._stack = []); } + static get setting() { + return Settings.getSetting('ui', 'default', 'enable-toasts'); + } + + static get enabled() { + return this.setting.value; + } + + static set enabled(enabled) { + this.setting.value = enabled; + } + } diff --git a/client/webpack.config.js b/client/webpack.config.js index 9278ddfc..efaea41d 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -40,7 +40,7 @@ module.exports = { crypto: 'require("crypto")', request: 'require(require("path").join(require("electron").remote.app.getAppPath(), "node_modules", "request"))', sparkplug: 'require("../../core/dist/sparkplug")', - crypto: 'require("crypto")' + '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 { diff --git a/core/src/main.js b/core/src/main.js index a7c712ee..d6694afc 100644 --- a/core/src/main.js +++ b/core/src/main.js @@ -257,9 +257,11 @@ export class BetterDiscord { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { for (let [header, values] of Object.entries(details.responseHeaders)) { if (!header.match(/^Content-Security-Policy(-Report-Only)?$/i)) continue; + details.responseHeaders[header] = values.map(value => { const policy = new ContentSecurityPolicy(value); for (const [key, value] of Object.entries(CSP)) { + if (!policy.get(key)) continue; policy.add(key, value.join(' ')); } return policy.toString(); diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 14086f32..042c796f 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -81,6 +81,8 @@ gulp.task('node-sass-bindings', function () { ]); }); +gulp.task('build-release', gulp.parallel('release-package', 'client', 'core', 'sparkplug', 'core-modules', 'index', 'css-editor', gulp.series('dependencies', 'node-sass-bindings'))); + gulp.task('release', gulp.series(function () { return del(['release/**/*']); -}, gulp.parallel('release-package', 'client', 'core', 'sparkplug', 'core-modules', 'index', 'css-editor', gulp.series('dependencies', 'node-sass-bindings')))); +}, 'build-release')); diff --git a/package.json b/package.json index 5766ca76..457204b4 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "build_node-sass": "node scripts/build-node-sass.js", "build_release": "npm run release --prefix client && npm run build --prefix core && npm run release --prefix csseditor", "package_release": "node scripts/package-release.js", - "release": "npm run lint && npm run build_release && gulp release && npm run package_release" + "release": "npm run lint && npm run build_release && gulp release && npm run package_release", + "update_release": "npm run build_release && gulp build-release" } }