Merge branch 'security' into security

This commit is contained in:
Mega-Mewthree 2018-08-13 10:35:16 -07:00 committed by GitHub
commit 81c451d31f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 595 additions and 99 deletions

View File

@ -8,33 +8,34 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import { Settings } from 'modules'; import { Settings, Cache } from 'modules';
import BuiltinModule from './BuiltinModule'; 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 { VueInjector, Reflection } from 'ui';
import { ClientLogger as Logger } from 'common'; import { ClientLogger as Logger } from 'common';
import { request } from 'vendor';
import { Utils } from 'common';
import E2EEComponent from './E2EEComponent.vue'; import E2EEComponent from './E2EEComponent.vue';
import E2EEMessageButton from './E2EEMessageButton.vue'; import E2EEMessageButton from './E2EEMessageButton.vue';
import aes256 from 'aes256'; import nodecrypto from 'node-crypto';
import crypto from 'crypto';
const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g"); const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g");
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g"); const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g");
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`); 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 { export default new class E2EE extends BuiltinModule {
constructor() { constructor() {
super(); super();
this.master = this.encrypt(seed, 'temporarymasterkey');
this.encryptNewMessages = true; this.encryptNewMessages = true;
} }
setMaster(key) { setMaster(key) {
seed = Math.random().toString(36).replace(/[^a-z]+/g, ''); seed = Security.randomBytes();
const newMaster = this.encrypt(seed, key); const newMaster = Security.encrypt(seed, key);
// TODO re-encrypt everything with new master // TODO re-encrypt everything with new master
return (this.master = newMaster); return (this.master = newMaster);
} }
@ -49,11 +50,28 @@ export default new class E2EE extends BuiltinModule {
} }
encrypt(key, content, prefix = '') { 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 = '') { 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) { getKey(channelId) {
@ -73,6 +91,9 @@ export default new class E2EE extends BuiltinModule {
} }
async enabled(e) { async enabled(e) {
seed = Security.randomBytes();
// TODO Input modal for key
this.master = Security.encrypt(seed, TEMP_KEY);
this.patchDispatcher(); this.patchDispatcher();
this.patchMessageContent(); this.patchMessageContent();
const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton'); const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton');
@ -119,6 +140,8 @@ export default new class E2EE extends BuiltinModule {
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector }); 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).before('render', this.beforeRenderMessageContent.bind(this));
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).after('render', this.renderMessageContent.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) { beforeRenderMessageContent(component) {
@ -137,7 +160,7 @@ export default new class E2EE extends BuiltinModule {
if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string
let decrypt; let decrypt;
try { 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 } catch (err) { return } // Ignore errors such as non empty
component.props.message.bd_encrypted = true; // signal as encrypted component.props.message.bd_encrypted = true; // signal as encrypted
@ -166,10 +189,77 @@ export default new class E2EE extends BuiltinModule {
renderMessageContent(component, args, retVal) { renderMessageContent(component, args, retVal) {
if (!component.props.message.bd_encrypted) return; 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, { beforeRenderImageWrapper(component, args, retVal) {
message: component.props.message 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) { patchChannelTextArea(cta) {
@ -215,7 +305,7 @@ export default new class E2EE extends BuiltinModule {
handleChannelTextAreaSubmit(component, args, retVal) { handleChannelTextAreaSubmit(component, args, retVal) {
const key = this.getKey(DiscordApi.currentChannel.id); const key = this.getKey(DiscordApi.currentChannel.id);
if (!this.encryptNewMessages || !key) return; 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) { async disabled(e) {

View File

@ -10,19 +10,26 @@
<template> <template>
<div class="bd-e2eeTaContainer" @contextmenu.prevent="channelType === 'DM' && $refs.ee2eLockContextMenu.open()"> <div class="bd-e2eeTaContainer" @contextmenu.prevent="channelType === 'DM' && $refs.ee2eLockContextMenu.open()">
<div v-if="error" class="bd-e2eeTaBtn bd-e2eeLock bd-error"> <v-popover popoverClass="bd-popover bd-e2eePopover" placement="top">
<MiLock v-tooltip="error" /> <div v-if="error" class="bd-e2eeTaBtn bd-e2eeLock bd-error">
</div> <MiLock v-tooltip="error" />
<div v-else-if="state === 'loading'" class="bd-e2eeTaBtn bd-e2eeLock bd-loading bd-warn"> </div>
<MiLock v-tooltip="'Loading'" /> <div v-else-if="state === 'loading'" class="bd-e2eeTaBtn bd-e2eeLock bd-loading bd-warn">
</div> <MiLock v-tooltip="'Loading'" />
<div v-else-if="!E2EE.encryptNewMessages" class="bd-e2eeTaBtn bd-e2eeLock bd-warn" @click="E2EE.encryptNewMessages = true"> </div>
<MiLock v-tooltip="'New messages will not be encrypted.'" />
</div>
<div v-else class="bd-e2eeTaBtn bd-e2eeLock bd-ok" @click="E2EE.encryptNewMessages = false">
<MiLock v-tooltip="'Ready!'" />
</div>
<div v-else-if="!E2EE.encryptNewMessages" class="bd-e2eeTaBtn bd-e2eeLock bd-warn">
<MiLock v-tooltip="'New messages will not be encrypted.'" />
</div>
<div v-else class="bd-e2eeTaBtn bd-e2eeLock bd-ok">
<MiLock v-tooltip="'Ready!'" />
</div>
<template slot="popover">
<div @click="toggleEncrypt" :class="{'bd-warn': !E2EE.encryptNewMessages, 'bd-ok': E2EE.encryptNewMessages}"><MiLock size="16" v-tooltip="'Toggle Encryption'" /></div>
<div v-close-popover @click="showUploadDialog" v-if="!error"><MiImagePlus size="16" v-tooltip="'Upload Encrypted Image'" /></div>
</template>
</v-popover>
<div class="bd-taDivider"></div> <div class="bd-taDivider"></div>
<context-menu id="bd-e2eeLockContextMenu" class="bd-e2eeLockContextMenu" ref="ee2eLockContextMenu" v-if="channelType === 'DM'"> <context-menu id="bd-e2eeLockContextMenu" class="bd-e2eeLockContextMenu" ref="ee2eLockContextMenu" v-if="channelType === 'DM'">
<li class="bd-e2eeLockContextMenuOption" @click="generatePublicKey()">Generate Public Key</li> <li class="bd-e2eeLockContextMenuOption" @click="generatePublicKey()">Generate Public Key</li>
@ -31,42 +38,20 @@
</div> </div>
</template> </template>
<script> <script>
import fs from 'fs';
import { Utils } from 'common';
import { remote } from 'electron';
import { E2EE } from 'builtin'; import { E2EE } from 'builtin';
import { DiscordApi } from 'modules'; import { DiscordApi, Security } from 'modules';
import { MiLock } from '../ui/components/common/MaterialIcon'; import { MiLock, MiPlus, MiImagePlus } from '../ui/components/common/MaterialIcon';
import contextMenu from 'vue-context-menu'; import contextMenu from 'vue-context-menu';
import { clipboard } from 'electron';
import { Toasts } from 'ui'; import { Toasts } from 'ui';
function generatePublicKey() {
const dmChannelID = location.pathname.split("/")[3];
const publicKeyMessage = `My public key is: \`${E2EE.createKeyExchange(dmChannelID)}\`. Please give me your public key if you haven't done so and add my public key by pasting it in the chat textbox, right clicking the lock icon, and selecting \`Receive Public Key\`.`;
const chatInput = document.getElementsByClassName('da-textArea')[0];
chatInput.value = publicKeyMessage;
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
}
function computeSharedSecret() {
try {
const dmChannelID = location.pathname.split("/")[3];
const chatInput = document.getElementsByClassName('da-textArea')[0];
const otherPublicKey = chatInput.value;
const secret = E2EE.computeSecret(dmChannelID, otherPublicKey);
E2EE.setKey(dmChannelID, secret);
chatInput.value = "";
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
Toasts.success("Encryption key has been set for this DM channel.");
} catch (e) {
Toasts.error("Invalid public key. Please set up a new key exchange.");
console.error(e);
}
}
export default { export default {
components: { MiLock, contextMenu }, components: { MiLock, MiPlus, MiImagePlus },
data() { data() {
return { return {
E2EE, E2EE,
@ -75,7 +60,60 @@
channelType: DiscordApi.currentChannel.type channelType: DiscordApi.currentChannel.type
}; };
}, },
methods: { generatePublicKey, computeSharedSecret }, methods: {
async showUploadDialog() {
const dialogResult = remote.dialog.showOpenDialog({ properties: ['openFile'] });
if (!dialogResult) return;
const readFile = fs.readFileSync(dialogResult[0]);
const FileActions = _bd.WebpackModules.getModuleByProps(["makeFile"]);
const Uploader = _bd.WebpackModules.getModuleByProps(["instantBatchUpload"]);
const img = await Utils.getImageFromBuffer(readFile);
const canvas = document.createElement('canvas');
canvas.height = img.height;
canvas.width = img.width;
const arrBuffer = await Utils.canvasToArrayBuffer(canvas);
const encrypted = E2EE.encrypt(img.src.replace('data:;base64,', ''));
const hmac = await E2EE.createHmac(encrypted);
const encodedBytes = new TextEncoder().encode(encrypted + hmac);
Uploader.upload(DiscordApi.currentChannel.id, FileActions.makeFile(new Uint8Array([...new Uint8Array(arrBuffer), ...encodedBytes]), 'bde2ee.png'));
},
toggleEncrypt() {
const newState = !E2EE.encryptNewMessages;
E2EE.encryptNewMessages = newState;
if (!newState) {
Toasts.warning('New messages will not be encrypted');
return;
}
Toasts.success('New messages will be encrypted');
},
generatePublicKey() {
const dmChannelID = location.pathname.split("/")[3];
const publicKeyMessage = `My public key is: \`${E2EE.createKeyExchange(dmChannelID)}\`. Please give me your public key if you haven't done so and add my public key by pasting it in the chat textbox, right clicking the lock icon, and selecting \`Receive Public Key\`.`;
const chatInput = document.getElementsByClassName('da-textArea')[0];
chatInput.value = publicKeyMessage;
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
},
computeSharedSecret() {
try {
const dmChannelID = location.pathname.split("/")[3];
const chatInput = document.getElementsByClassName('da-textArea')[0];
const otherPublicKey = chatInput.value;
const secret = E2EE.computeSecret(dmChannelID, otherPublicKey);
E2EE.setKey(dmChannelID, secret);
chatInput.value = "";
const evt = { currentTarget: chatInput };
chatInput[Object.keys(chatInput).find(k => k.startsWith('__reactEventHandlers'))].onChange.call(chatInput, evt);
Toasts.success("Encryption key has been set for this DM channel.");
} catch (e) {
Toasts.error("Invalid public key. Please set up a new key exchange.");
console.error(e);
}
}
},
mounted() { mounted() {
if (!E2EE.master) { if (!E2EE.master) {
this.error = 'No master key set!'; this.error = 'No master key set!';

View File

@ -37,7 +37,7 @@ export default new class VueDevtoolsModule extends BuiltinModule {
} }
devToolsOpened() { devToolsOpened() {
electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools'); electron.remote.BrowserWindow.removeDevToolsExtension('Vue.js devtools');
electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension'); electron.webFrame.registerURLSchemeAsPrivileged('chrome-extension');
try { try {
const res = electron.remote.BrowserWindow.addDevToolsExtension(path.join(Globals.getPath('ext'), 'extensions', 'vdt')); const res = electron.remote.BrowserWindow.addDevToolsExtension(path.join(Globals.getPath('ext'), 'extensions', 'vdt'));

View File

@ -93,6 +93,13 @@
"hint": "When this is enabled you can use Ctrl/Cmd + B to open the BetterDiscord settings menu.", "hint": "When this is enabled you can use Ctrl/Cmd + B to open the BetterDiscord settings menu.",
"value": false, "value": false,
"disabled": false "disabled": false
},
{
"id": "enable-toasts",
"type": "bool",
"text": "Enable Toasts",
"hint": "Allows plugins to show toasts.",
"value": true
} }
] ]
} }
@ -144,7 +151,7 @@
{ {
"id": "security", "id": "security",
"text": "Security and Privacy", "text": "Security and Privacy",
"headertext": "Security Settings", "headertext": "Security and Privacy Settings",
"settings": [ "settings": [
{ {
"id": "default", "id": "default",

View File

@ -10,7 +10,7 @@
import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts } from 'ui'; import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts } from 'ui';
import BdCss from './styles/index.scss'; import BdCss from './styles/index.scss';
import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, BdWebApi, Connectivity } from 'modules'; import { Events, CssEditor, Globals, Settings, Database, Updater, ModuleManager, PluginManager, ThemeManager, ExtModuleManager, Vendor, WebpackModules, Patcher, MonkeyPatch, ReactComponents, ReactHelpers, ReactAutoPatcher, DiscordApi, BdWebApi, Connectivity, Cache } from 'modules';
import { ClientLogger as Logger, ClientIPC, Utils } from 'common'; import { ClientLogger as Logger, ClientIPC, Utils } from 'common';
import { BuiltinManager, EmoteModule, ReactDevtoolsModule, VueDevtoolsModule, TrackingProtection, E2EE } from 'builtin'; import { BuiltinManager, EmoteModule, ReactDevtoolsModule, VueDevtoolsModule, TrackingProtection, E2EE } from 'builtin';
import electron from 'electron'; import electron from 'electron';
@ -37,6 +37,7 @@ class BetterDiscord {
EmoteModule, EmoteModule,
BdWebApi, BdWebApi,
Connectivity, Connectivity,
Cache,
Logger, ClientIPC, Utils, Logger, ClientIPC, Utils,
plugins: PluginManager.localContent, plugins: PluginManager.localContent,

View File

@ -0,0 +1,38 @@
/**
* BetterDiscord Cache 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.
*/
const CACHE = [];
export default class Cache {
static get cache() {
return CACHE;
}
/**
* Push something to cache
* @param {String} where Cache identifier
* @param {any} data Data to push
*/
static push(where, data) {
if (!this.cache[where]) this.cache[where] = [];
this.cache[where].push(data);
}
/**
* Find something in cache
* @param {String} where Cache identifier
* @param {Function} what Find callback
*/
static find(where, what) {
if (!this.cache[where]) this.cache[where] = [];
return this.cache[where].find(what);
}
}

View File

@ -25,3 +25,4 @@ export { default as DiscordApi, Modules as DiscordApiModules } from './discordap
export { default as BdWebApi } from './bdwebapi'; export { default as BdWebApi } from './bdwebapi';
export { default as Connectivity } from './connectivity'; export { default as Connectivity } from './connectivity';
export { default as Security } from './security'; export { default as Security } from './security';
export { default as Cache } from './cache';

View File

@ -18,9 +18,9 @@ import { ClientLogger as Logger } from 'common';
/** /**
* A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely. * A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely.
* *
* The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches. * The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches.
* *
* @callback Patcher~patchCallback * @callback Patcher~patchCallback
* @param {object} thisObject - `this` in the context of the original function. * @param {object} thisObject - `this` in the context of the original function.
* @param {arguments} arguments - The original arguments of the original function. * @param {arguments} arguments - The original arguments of the original function.
@ -42,8 +42,8 @@ export class Patcher {
const patches = []; const patches = [];
for (const patch of this.patches) { for (const patch of this.patches) {
for (const childPatch of patch.children) { for (const childPatch of patch.children) {
if (childPatch.caller === id) patches.push(childPatch); if (childPatch.caller === id) patches.push(childPatch);
} }
} }
return patches; return patches;
} }
@ -135,7 +135,7 @@ export class Patcher {
/** /**
* This method patches onto another function, allowing your code to run beforehand. * This method patches onto another function, allowing your code to run beforehand.
* Using this, you are also able to modify the incoming arguments before the original method is run. * Using this, you are also able to modify the incoming arguments before the original method is run.
* *
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}. * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype. * @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched * @param {string} functionName - Name of the method to be patched
@ -148,7 +148,7 @@ export class Patcher {
/** /**
* This method patches onto another function, allowing your code to run afterwards. * This method patches onto another function, allowing your code to run afterwards.
* Using this, you are also able to modify the return value, using the return of your code instead. * Using this, you are also able to modify the return value, using the return of your code instead.
* *
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}. * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype. * @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched * @param {string} functionName - Name of the method to be patched
@ -161,7 +161,7 @@ export class Patcher {
/** /**
* This method patches onto another function, allowing your code to run instead, preventing the running of the original code. * This method patches onto another function, allowing your code to run instead, preventing the running of the original code.
* Using this, you are also able to modify the return value, using the return of your code instead. * Using this, you are also able to modify the return value, using the return of your code instead.
* *
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}. * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype. * @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched * @param {string} functionName - Name of the method to be patched
@ -175,7 +175,7 @@ export class Patcher {
* This method patches onto another function, allowing your code to run before, instead or after the original function. * This method patches onto another function, allowing your code to run before, instead or after the original function.
* Using this you are able to modify the incoming arguments before the original function is run as well as the return * Using this you are able to modify the incoming arguments before the original function is run as well as the return
* value before the original function actually returns. * value before the original function actually returns.
* *
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}. * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link Patcher#unpatchAll}.
* @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype. * @param {object} unresolvedModule - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched * @param {string} functionName - Name of the method to be patched
@ -201,10 +201,10 @@ export class Patcher {
unpatch: () => { unpatch: () => {
patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1); patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1);
if (patch.children.length <= 0) { if (patch.children.length <= 0) {
const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName); const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName);
this.patches[patchNum].revert(); this.patches[patchNum].revert();
this.patches.splice(patchNum, 1); this.patches.splice(patchNum, 1);
} }
} }
}; };
patch.children.push(child); patch.children.push(child);

View File

@ -330,7 +330,8 @@ export default class PluginApi {
success: this.showSuccessToast.bind(this), success: this.showSuccessToast.bind(this),
error: this.showErrorToast.bind(this), error: this.showErrorToast.bind(this),
info: this.showInfoToast.bind(this), info: this.showInfoToast.bind(this),
warning: this.showWarningToast.bind(this) warning: this.showWarningToast.bind(this),
get enabled() { return Toasts.enabled }
}; };
} }

View File

@ -8,21 +8,25 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import nodecrypto from 'node-crypto';
import aes256 from 'aes256'; import aes256 from 'aes256';
export default class Security { export default class Security {
static encrypt(key, content, prefix = '') { static encrypt(key, content, prefix = '') {
if (key instanceof Array) return this.deepEncrypt(key, content, prefix); if (key instanceof Array || content instanceof Array) return this.deepEncrypt(key, content, prefix);
return `${prefix}${aes256.encrypt(key, content)}`; return `${prefix}${aes256.encrypt(key, content)}`;
} }
static decrypt(key, content, prefix = '') { static decrypt(key, content, prefix = '') {
if (key instanceof Array) return this.deepDecrypt(key, content, prefix); if (key instanceof Array || content instanceof Array) {
return this.deepDecrypt(key, content, prefix);
}
return aes256.decrypt(key, content.replace(prefix, '')); return aes256.decrypt(key, content.replace(prefix, ''));
} }
static deepEncrypt(keys, content, prefix = '') { static deepEncrypt(keys, content, prefix = '') {
if (content && content instanceof Array) return this.deepEncryptContent(keys, content, prefix);
let encrypt = null; let encrypt = null;
for (const key of keys) { for (const key of keys) {
if (encrypt === null) encrypt = this.encrypt(key, content, prefix); if (encrypt === null) encrypt = this.encrypt(key, content, prefix);
@ -31,7 +35,17 @@ export default class Security {
return encrypt; return encrypt;
} }
static deepEncryptContent(key, contents, prefix = '') {
let encrypt = null;
for (const content of contents) {
if (encrypt === null) encrypt = this.encrypt(key, content, prefix);
else encrypt = this.encrypt(encrypt, content, prefix);
}
return encrypt;
}
static deepDecrypt(keys, content, prefix = '') { static deepDecrypt(keys, content, prefix = '') {
if (content && content instanceof Array) return this.deepDecryptContent(keys, content, prefix);
let decrypt = null; let decrypt = null;
for (const key of keys.reverse()) { for (const key of keys.reverse()) {
if (decrypt === null) decrypt = this.decrypt(key, content, prefix); if (decrypt === null) decrypt = this.decrypt(key, content, prefix);
@ -40,4 +54,30 @@ export default class Security {
return decrypt; return decrypt;
} }
static deepDecryptContent(key, contents, prefix = '') {
let decrypt = null;
for (const content of contents) {
if (decrypt === null) decrypt = this.decrypt(key, content, prefix);
else decrypt = this.decrypt(decrypt, content, prefix);
}
return decrypt;
}
static randomBytes(length = 64, to = 'hex') {
return nodecrypto.randomBytes(length).toString(to);
}
static async createHmac(key, data, algorithm = 'sha256') {
const hmac = nodecrypto.createHmac(algorithm, key);
return new Promise((resolve, reject) => {
hmac.on('readable', () => {
const data = hmac.read();
if (data) return resolve(data.toString('hex'));
reject(null);
});
hmac.write(data);
hmac.end();
});
}
} }

View File

@ -11,10 +11,11 @@
import Kvp from './kvp'; import Kvp from './kvp';
export default class SecureKvpSetting extends Kvp { export default class SecureKvpSetting extends Kvp {
/** /**
* The value to use when the setting doesn't have a value. * The value to use when the setting doesn't have a value.
*/ */
get defaultValue() { get defaultValue() {
return { key: 'Key', value: '**********' }; return { key: 'PlaceholderKey', value: '' };
} }
} }

View File

@ -44,11 +44,14 @@
} }
&.bd-hide-button { &.bd-hide-button {
transition: opacity 0.4s ease-out; animation: bd-fade-out 0.4s ease-out;
opacity: 0;
&.bd-active { &.bd-active {
transition-timing-function: ease-in; animation: bd-fade-in 0.4s ease-in;
}
&:not(.bd-active):not(.bd-animating) {
display: none;
} }
} }

View File

@ -16,7 +16,7 @@
&.bd-e2eeLock { &.bd-e2eeLock {
&.bd-error { &.bd-error {
fill: #cc3e3e; fill: $colerr;
} }
&.bd-ok { &.bd-ok {
@ -24,7 +24,7 @@
} }
&.bd-warn { &.bd-warn {
fill: #ccab3e; fill: $colwarn;
animation: bd-pulse 2s ease-in-out infinite; animation: bd-pulse 2s ease-in-out infinite;
} }
} }
@ -75,7 +75,7 @@
&.bd-e2eeLock { &.bd-e2eeLock {
&.bd-error { &.bd-error {
fill: #cc3e3e; fill: $colerr;
} }
&.bd-ok { &.bd-ok {
@ -83,7 +83,7 @@
} }
&.bd-warn { &.bd-warn {
fill: #ccab3e; fill: $colwarn;
} }
&.bd-loading { &.bd-loading {
@ -106,3 +106,87 @@
} }
} }
} }
.bd-e2eePopover {
background: #484b51;
margin: 0;
margin-top: 15px;
.bd-ok svg {
fill: $colbdgreen;
}
.bd-warn svg {
fill: $colwarn;
}
.bd-popover-wrapper,
.bd-popoverWrapper {
.bd-popover-inner,
.bd-popoverInner {
display: flex;
> div:first-child {
display: flex;
> div:not(:first-child) {
margin-left: 10px;
}
}
.bd-material-design-icon,
.bd-materialDesignIcon {
display: flex;
fill: #7e8084;
cursor: pointer;
&:hover {
fill: #FFF;
}
}
}
}
.bd-popover-arrow,
.bd-popoverArrow {
display: none;
}
}
.bd-encryptedImage::before {
content: "";
position: absolute;
background: $colbdgreen;
width: 100%;
height: 100%;
border-radius: 3px;
display: flex;
// justify-content: center;
// align-items: flex-start;
// font-size: 1.2em;
// font-weight: 700;
// color: #2c2c2c;
// line-height: 30px;
background-image: $lockIcon;
background-size: calc(100% / 2);
background-repeat: no-repeat;
background-position: center;
}
.bd-decryptedImage::before {
content: "";
background-image: $lockIcon;
width: 11px;
height: 11px;
position: absolute;
z-index: 1;
display: block;
background-size: cover;
background-color: $colbdgreen;
background-repeat: no-repeat;
border-radius: 100%;
border: 2px solid $colbdgreen;
top: 5px;
left: 5px;
opacity: .5;
}

View File

@ -0,0 +1,65 @@
.bd-formCollection {
display: flex;
flex-direction: column;
> :first-child {
flex: 1 1 auto;
}
.bd-collectionItem {
display: flex;
flex-grow: 1;
margin-top: 5px;
> :first-child {
flex: 1 0 auto;
}
.bd-removeCollectionItem {
width: 20px;
flex: 0 1 auto;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
&:not(.bd-disabled):hover {
svg {
fill: #fff;
}
}
&.bd-disabled {
opacity: 0.5;
}
}
}
.bd-newCollectionItem {
display: flex;
cursor: pointer;
align-self: flex-end;
justify-content: center;
align-items: center;
margin-right: 2px;
margin-top: 10px;
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
&:hover {
svg {
fill: #FFF;
}
}
}
}

View File

@ -9,3 +9,4 @@
@import './files.scss'; @import './files.scss';
@import './guilds.scss'; @import './guilds.scss';
@import './arrays.scss'; @import './arrays.scss';
@import './collections.scss';

View File

@ -3,5 +3,5 @@ $colbdgreen: #3ecc9c;
$colbdblue: $colbdgreen; $colbdblue: $colbdgreen;
$colerr: #d84040; $colerr: #d84040;
$colwarn: #faa61a; $colwarn: #faa61a;
$colok: #43b581; $colok: $colbdgreen;
$coldimwhite: #b9bbbe; $coldimwhite: #b9bbbe;

File diff suppressed because one or more lines are too long

View File

@ -65,8 +65,7 @@ export default class ClassNormaliser extends Module {
normalizeElement(element) { normalizeElement(element) {
if (!(element instanceof Element)) return; if (!(element instanceof Element)) return;
if (element.children && element.children.length) this.normalizeElement(element.children[0]);
if (element.nextElementSibling) this.normalizeElement(element.nextElementSibling);
const classes = element.classList; const classes = element.classList;
for (let c = 0, clen = classes.length; c < clen; c++) { for (let c = 0, clen = classes.length; c < clen; c++) {
if (!randClass.test(classes[c])) continue; if (!randClass.test(classes[c])) continue;
@ -74,6 +73,10 @@ export default class ClassNormaliser extends Module {
const newClass = match.split('-').map((s, i) => i ? s[0].toUpperCase() + s.slice(1) : s).join(''); const newClass = match.split('-').map((s, i) => i ? s[0].toUpperCase() + s.slice(1) : s).join('');
element.classList.add(`${normalizedPrefix}-${newClass}`); element.classList.add(`${normalizedPrefix}-${newClass}`);
} }
for (let child of element.children) {
this.normalizeElement(child);
}
} }
} }

View File

@ -47,7 +47,7 @@
methods: { methods: {
keyupListener(e) { keyupListener(e) {
if (Modals.stack.length || !this.active || e.which !== 27) return; if (Modals.stack.length || !this.active || e.which !== 27) return;
if (this.$refs.settings.activeIndex !== -1) this.$refs.settings.closeContent(); if (this.$refs.settings.item) this.$refs.settings.closeContent();
else this.active = false; else this.active = false;
e.stopImmediatePropagation(); e.stopImmediatePropagation();
}, },

View File

@ -11,10 +11,10 @@
<template> <template>
<div class="bd-formCollection"> <div class="bd-formCollection">
<div v-for="s in setting.items" class="bd-collectionItem"> <div v-for="s in setting.items" class="bd-collectionItem">
<Setting :setting="s" :key="s.id" /> <Setting :setting="s" :hide-divider="true" :key="s.id" />
<div class="bd-removeCollectionItem" @click="removeItem(s)"><MiMinus/></div> <div class="bd-removeCollectionItem" :class="{'bd-disabled': setting.disabled || setting.min && setting.items.length <= setting.min}" @click="removeItem(s)"><MiMinus/></div>
</div> </div>
<div class="bd-newCollectionItem" @click="addItem"><MiPlus/></div> <div v-if="!setting.disabled && !setting.max || setting.items.length < setting.max" class="bd-newCollectionItem" @click="addItem"><MiPlus/></div>
</div> </div>
</template> </template>
@ -29,9 +29,11 @@
}, },
methods: { methods: {
removeItem(item) { removeItem(item) {
if (this.setting.disabled || this.setting.min && this.setting.items.length <= this.setting.min) return;
this.setting.removeItem(item); this.setting.removeItem(item);
}, },
addItem() { addItem() {
if (this.setting.disabled || this.setting.max && this.setting.items.length >= this.setting.max) return;
this.setting.addItem(); this.setting.addItem();
} }
}, },

View File

@ -22,11 +22,12 @@
</template> </template>
<script> <script>
import aes256 from 'aes256'; import { DiscordApi } from 'modules';
import { E2EE } from 'builtin';
export default { export default {
data() { data() {
return { return {
masterKey: 'temporarymasterkey',
valueChanged: false valueChanged: false
} }
}, },
@ -40,7 +41,7 @@
}, },
valueBlur(e) { valueBlur(e) {
if (!this.valueChanged) return; if (!this.valueChanged) return;
const value = aes256.encrypt(this.masterKey, e.target.value); const value = E2EE.encrypt(null, e.target.value);
this.setting.value = { key: this.setting.value.key, value } this.setting.value = { key: this.setting.value.key, value }
this.valueChanged = false; this.valueChanged = false;
}, },
@ -52,6 +53,10 @@
if (e.key !== 'Enter') return; if (e.key !== 'Enter') return;
e.target.blur(); e.target.blur();
} }
},
beforeMount() {
if (this.setting.value.key !== 'PlaceholderKey') return;
this.setting.value.key = DiscordApi.currentChannel.id || 'Key';
} }
} }
</script> </script>

View File

@ -26,7 +26,8 @@
<KeyValuePair v-else-if="setting.type === 'kvp'" :setting="setting" /> <KeyValuePair v-else-if="setting.type === 'kvp'" :setting="setting" />
<SecureKeyValuePair v-else-if="setting.type === 'securekvp'" :setting="setting" /> <SecureKeyValuePair v-else-if="setting.type === 'securekvp'" :setting="setting" />
<CustomSetting v-else-if="setting.type === 'custom'" :setting="setting" /> <CustomSetting v-else-if="setting.type === 'custom'" :setting="setting" />
<div class="bd-form-divider"></div>
<div v-if="!hideDivider" class="bd-form-divider"></div>
</div> </div>
</template> </template>
@ -51,7 +52,8 @@
export default { export default {
props: [ props: [
'setting' 'setting',
'hide-divider'
], ],
components: { components: {
BoolSetting, BoolSetting,

View File

@ -19,3 +19,4 @@ export { default as MiWarning } from './materialicons/Warning.vue';
export { default as MiSuccess } from './materialicons/Success.vue'; export { default as MiSuccess } from './materialicons/Success.vue';
export { default as AccountCircle } from './materialicons/AccountCircle.vue'; export { default as AccountCircle } from './materialicons/AccountCircle.vue';
export { default as MiLock } from './materialicons/Lock.vue'; export { default as MiLock } from './materialicons/Lock.vue';
export { default as MiImagePlus } from './materialicons/ImagePlus.vue';

View File

@ -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
*/
<template>
<span class="bd-material-design-icon">
<svg :width="size || 24" :height="size || 24" viewBox="0 0 24 24">
<path d="M 5,3C 3.89543,3 3,3.8954 3,5L 3,19C 3,20.1046 3.89543,21 5,21L 14.0859,21C 14.0294,20.6696 14.0007,20.3351 14,20C 14.0027,19.3182 14.1216,18.6418 14.3516,18L 5,18L 8.5,13.5L 11,16.5L 14.5,12L 16.7285,14.9727C 17.7019,14.3386 18.8383,14.0007 20,14C 20.3353,14.002 20.6698,14.032 21,14.0898L 21,5C 21,3.89 20.1,3 19,3L 5,3 Z M 19,16L 19,19L 16,19L 16,21L 19,21L 19,24L 21,24L 21,21L 24,21L 24,19L 21,19L 21,16L 19,16 Z " />
</svg>
</span>
</template>
<script>
export default {
props: ['size']
}
</script>

View File

@ -8,6 +8,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import { Settings } from 'modules';
let toasts = 0; let toasts = 0;
export default class Toasts { 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. * @returns {Promise} This promise resolves when the toast is removed from the DOM.
*/ */
static async push(message, options = {}) { static async push(message, options = {}) {
if (!this.enabled) return;
const {type = 'basic', icon, additionalClasses, timeout = 3000} = options; const {type = 'basic', icon, additionalClasses, timeout = 3000} = options;
const toast = {id: toasts++, message, type, icon, additionalClasses, closing: false}; const toast = {id: toasts++, message, type, icon, additionalClasses, closing: false};
this.stack.push(toast); this.stack.push(toast);
@ -72,4 +76,16 @@ export default class Toasts {
return this._stack || (this._stack = []); 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;
}
} }

View File

@ -40,7 +40,7 @@ module.exports = {
crypto: 'require("crypto")', crypto: 'require("crypto")',
request: 'require(require("path").join(require("electron").remote.app.getAppPath(), "node_modules", "request"))', request: 'require(require("path").join(require("electron").remote.app.getAppPath(), "node_modules", "request"))',
sparkplug: 'require("../../core/dist/sparkplug")', sparkplug: 'require("../../core/dist/sparkplug")',
crypto: 'require("crypto")' 'node-crypto': 'require("crypto")'
}, },
resolve: { resolve: {
alias: { alias: {

View File

@ -176,6 +176,69 @@ export class Utils {
} while (!value); } while (!value);
return 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 { export class FileUtils {

View File

@ -257,9 +257,11 @@ export class BetterDiscord {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
for (let [header, values] of Object.entries(details.responseHeaders)) { for (let [header, values] of Object.entries(details.responseHeaders)) {
if (!header.match(/^Content-Security-Policy(-Report-Only)?$/i)) continue; if (!header.match(/^Content-Security-Policy(-Report-Only)?$/i)) continue;
details.responseHeaders[header] = values.map(value => { details.responseHeaders[header] = values.map(value => {
const policy = new ContentSecurityPolicy(value); const policy = new ContentSecurityPolicy(value);
for (const [key, value] of Object.entries(CSP)) { for (const [key, value] of Object.entries(CSP)) {
if (!policy.get(key)) continue;
policy.add(key, value.join(' ')); policy.add(key, value.join(' '));
} }
return policy.toString(); return policy.toString();

View File

@ -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 () { gulp.task('release', gulp.series(function () {
return del(['release/**/*']); return del(['release/**/*']);
}, gulp.parallel('release-package', 'client', 'core', 'sparkplug', 'core-modules', 'index', 'css-editor', gulp.series('dependencies', 'node-sass-bindings')))); }, 'build-release'));

View File

@ -82,6 +82,7 @@
"build_node-sass": "node scripts/build-node-sass.js", "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", "build_release": "npm run release --prefix client && npm run build --prefix core && npm run release --prefix csseditor",
"package_release": "node scripts/package-release.js", "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"
} }
} }