Merge pull request #223 from JsSucks/security

Security
This commit is contained in:
Alexei Stukov 2018-08-15 02:39:36 +03:00 committed by GitHub
commit 462b198895
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1193 additions and 35 deletions

View File

@ -8,20 +8,351 @@
* LICENSE file in the root directory of this source tree.
*/
import { Settings, Cache, Events } from 'modules';
import BuiltinModule from './BuiltinModule';
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi, Security } from 'modules';
import { VueInjector, Reflection, Modals, Toasts } 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 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+|$)`);
const START_DATE = new Date();
const TEMP_KEY = 'temporarymasterkey';
const ECDH_STORAGE = {};
let seed;
export default new class E2EE extends BuiltinModule {
constructor() {
super();
this.encryptNewMessages = true;
this.ecdhDate = START_DATE;
this.handlePublicKey = this.handlePublicKey.bind(this);
}
async enabled(e) {
try {
const newMaster = await Modals.input('Open Database', 'Master Password', true).promise;
this.setMaster(newMaster);
this.patchDispatcher();
this.patchMessageContent();
const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton');
const cta = await ReactComponents.getComponent('ChannelTextArea', { selector });
this.patchChannelTextArea(cta);
this.patchChannelTextAreaSubmit(cta);
cta.forceUpdateAll();
} catch (err) {
Settings.getSetting(...this.settingPath).value = false;
Toasts.error('Invalid master password! E2EE Disabled');
}
}
async disabled(e) {
Events.off('discord:MESSAGE_CREATE', this.handlePublicKey);
for (const patch of Patcher.getPatchesByCaller('BD:E2EE')) patch.unpatch();
const ctaComponent = await ReactComponents.getComponent('ChannelTextArea');
ctaComponent.forceUpdateAll();
}
setMaster(key) {
seed = Security.randomBytes();
const newMaster = Security.encrypt(seed, key);
// TODO re-encrypt everything with new master
return (this.master = newMaster);
}
get settingPath() {
return ['security', 'default', 'e2ee'];
}
enabled(e) {
get database() {
return Settings.getSetting('security', 'e2eedb', 'e2ekvps').value;
}
disabled(e) {
encrypt(key, content, prefix = '') {
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 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) {
const haveKey = this.database.find(kvp => kvp.value.key === channelId);
if (!haveKey) return null;
return haveKey.value.value;
}
setKey(channelId, key) {
const items = Settings.getSetting('security', 'e2eedb', 'e2ekvps').items;
const index = items.findIndex(kvp => kvp.value.key === channelId);
if (index > -1) {
items[index].value = {key: channelId, value: key};
return;
}
Settings.getSetting('security', 'e2eedb', 'e2ekvps').addItem({ value: { key: channelId, value: key } });
}
createKeyExchange(dmChannelID) {
if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) return null;
ECDH_STORAGE[dmChannelID] = Security.createECDH();
setTimeout(() => {
if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) {
delete ECDH_STORAGE[dmChannelID];
Toasts.error('Key exchange expired!');
if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
this.preExchangeState = null;
}
}, 30000);
return Security.generateECDHKeys(ECDH_STORAGE[dmChannelID]);
}
publicKeyFor(dmChannelID) {
return Security.getECDHPublicKey(ECDH_STORAGE[dmChannelID]);
}
computeSecret(dmChannelID, otherKey) {
try {
const secret = Security.computeECDHSecret(ECDH_STORAGE[dmChannelID], otherKey);
delete ECDH_STORAGE[dmChannelID];
return Security.hash('sha384', secret, 'hex');
} catch (e) {
throw e;
}
}
// TODO Received exchange should also expire if not accepted in time
async handlePublicKey(e) {
if (!DiscordApi.currentChannel) return;
if (DiscordApi.currentChannel.type !== 'DM') return;
const { id, content, author, channelId } = e.args;
if (author.id === DiscordApi.currentUser.id || channelId !== DiscordApi.currentChannel.id) return;
const [tagstart, begin, key, end, tagend] = content.split('\n');
if (begin !== '-----BEGIN PUBLIC KEY-----' || end !== '-----END PUBLIC KEY-----') return;
try {
await Modals.confirm('Key Exchange', `Key exchange request from: ${author.username}#${author.discriminator}`, 'Accept', 'Reject').promise;
// We already sent our key
if (!ECDH_STORAGE.hasOwnProperty(channelId)) {
const publicKeyMessage = `\`\`\`\n-----BEGIN PUBLIC KEY-----\n${this.createKeyExchange(channelId)}\n-----END PUBLIC KEY-----\n\`\`\``;
if (this.encryptNewMessages) this.encryptNewMessages = false;
WebpackModules.getModuleByName('DraftActions').saveDraft(channelId, publicKeyMessage);
}
const secret = this.computeSecret(channelId, key);
this.setKey(channelId, secret);
Toasts.success('Key exchange complete!');
if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
this.preExchangeState = null;
} catch (err) {
console.log(err);
return;
}
}
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);
});
}
async patchMessageContent() {
const selector = '.' + WebpackModules.getClassName('container', 'containerCozy', 'containerCompact', 'edited');
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) {
if (!component.props || !component.props.message) return;
const key = this.getKey(component.props.message.channel_id);
if (!key) return; // We don't have a key for this channel
const Message = WebpackModules.getModuleByPrototypes(['isMentioned']);
const MessageParser = WebpackModules.getModuleByName('MessageParser');
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
let decrypt;
try {
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
// 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);
// Create a new message to parse it properly
const create = Message.create(message);
if (!create.content || !create.contentParsed) return;
component.props.message.mentions = create.mentions;
component.props.message.mentionRoles = create.mentionRoles;
component.props.message.mentionEveryone = create.mentionEveryone;
component.props.message.mentioned = create.mentioned;
component.props.message.content = create.content;
component.props.message.contentParsed = create.contentParsed;
}
renderMessageContent(component, args, retVal) {
if (!component.props.message.bd_encrypted) return;
const buttons = Utils.findInReactTree(retVal, m => Array.isArray(m) && m[1].props && m[1].props.currentUserId);
if (!buttons) return;
try {
buttons.unshift(VueInjector.createReactElement(E2EEMessageButton));
} catch (err) {
Logger.err('E2EE', err.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) {
MonkeyPatch('BD:E2EE', cta.component.prototype).after('render', this.renderChannelTextArea);
}
renderChannelTextArea(component, args, retVal) {
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'));
inner.props.children.splice(0, 0, VueInjector.createReactElement(E2EEComponent));
}
patchChannelTextAreaSubmit(cta) {
MonkeyPatch('BD:E2EE', cta.component.prototype).before('handleSubmit', this.handleChannelTextAreaSubmit.bind(this));
}
handleChannelTextAreaSubmit(component, args, retVal) {
const key = this.getKey(DiscordApi.currentChannel.id);
if (!this.encryptNewMessages || !key) return;
component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:');
}
}

View File

@ -0,0 +1,116 @@
/**
* BetterDiscord E2EE Component
* 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.
*/
<template>
<div class="bd-e2eeTaContainer">
<v-popover popoverClass="bd-popover bd-e2eePopover" placement="top">
<div v-if="error" class="bd-e2eeTaBtn bd-e2eeLock bd-error">
<MiLock v-tooltip="error" />
</div>
<div v-else-if="state === 'loading'" class="bd-e2eeTaBtn bd-e2eeLock bd-loading bd-warn">
<MiLock v-tooltip="'Loading'" />
</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>
<!-- Using these icons for now -->
<div v-close-popover @click="generatePublicKey" v-if="DiscordApi.currentChannel.type === 'DM'"><MiPencil size="16" v-tooltip="'Begin Key Exchange'" /></div>
</template>
</v-popover>
<div class="bd-taDivider"></div>
</div>
</template>
<script>
import fs from 'fs';
import { Utils } from 'common';
import { remote } from 'electron';
import { E2EE } from 'builtin';
import { DiscordApi, Security, WebpackModules } from 'modules';
import { MiLock, MiPlus, MiImagePlus, MiPencil, MiRefresh } from '../ui/components/common/MaterialIcon';
import { Toasts } from 'ui';
export default {
components: { MiLock, MiPlus, MiImagePlus, MiPencil, MiRefresh },
data() {
return {
E2EE,
state: 'loading',
error: null,
DiscordApi
};
},
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 keyExchange = E2EE.createKeyExchange(DiscordApi.currentChannel.id);
if (keyExchange === null) {
Toasts.warning('Key exchange for channel already in progress!');
return;
}
E2EE.preExchangeState = E2EE.encryptNewMessages;
E2EE.encryptNewMessages = false; // Disable encrypting new messages so we won't encrypt public keys
const publicKeyMessage = `\`\`\`\n-----BEGIN PUBLIC KEY-----\n${keyExchange}\n-----END PUBLIC KEY-----\n\`\`\``;
WebpackModules.getModuleByName('DraftActions').saveDraft(DiscordApi.currentChannel.id, publicKeyMessage);
Toasts.info('Key exchange started. Expires in 30 seconds');
}
},
mounted() {
if (!E2EE.master) {
this.error = 'No master key set!';
return;
}
const haveKey = E2EE.getKey(DiscordApi.currentChannel.id);
if (!haveKey) {
this.error = 'No key for channel!';
return;
}
this.state = 'OK';
this.error = null;
}
}
</script>

View File

@ -0,0 +1,28 @@
/**
* BetterDiscord E2EE Component
* 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.
*/
<template>
<div class="bd-e2eeMessageButtonWrap">
<div class="bd-e2eeMessageButton">
<MiLock v-tooltip="'Encrypted'" :size="16" />
</div>
</div>
</template>
<script>
import { MiLock } from '../ui/components/common/MaterialIcon';
export default {
components: {
MiLock
},
props: ['message']
}
</script>

View File

@ -2,6 +2,7 @@ import { default as EmoteModule } from './EmoteModule';
import { default as ReactDevtoolsModule } from './ReactDevtoolsModule';
import { default as VueDevtoolsModule } from './VueDevToolsModule';
import { default as TrackingProtection } from './TrackingProtection';
import { default as E2EE } from './E2EE';
export default class {
static initAll() {
@ -9,5 +10,6 @@ export default class {
ReactDevtoolsModule.init();
VueDevtoolsModule.init();
TrackingProtection.init();
E2EE.init();
}
}

View File

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

View File

@ -3,3 +3,4 @@ export { default as ReactDevtoolsModule } from './ReactDevtoolsModule';
export { default as VueDevtoolsModule } from './VueDevToolsModule';
export { default as TrackingProtection } from './TrackingProtection';
export { default as BuiltinManager } from './Manager';
export { default as E2EE } from './E2EE';

View File

@ -10,9 +10,9 @@
import { DOM, BdUI, BdMenu, Modals, Reflection, Toasts } from 'ui';
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 { BuiltinManager, EmoteModule, ReactDevtoolsModule, VueDevtoolsModule, TrackingProtection } from 'builtin';
import { BuiltinManager, EmoteModule, ReactDevtoolsModule, VueDevtoolsModule, TrackingProtection, E2EE } from 'builtin';
import electron from 'electron';
import path from 'path';
@ -37,6 +37,7 @@ class BetterDiscord {
EmoteModule,
BdWebApi,
Connectivity,
Cache,
Logger, ClientIPC, Utils,
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

@ -9,6 +9,7 @@
*/
import { WebpackModules } from './webpackmodules';
import { MonkeyPatch } from './patcher';
import Events from './events';
import EventListener from './eventlistener';
@ -21,6 +22,7 @@ import * as SocketStructs from '../structs/socketstructs';
export default class extends EventListener {
init() {
this.ignoreMultiple = -1;
this.hook();
}
@ -35,15 +37,21 @@ export default class extends EventListener {
}
hook() {
const self = this;
const Events = WebpackModules.getModuleByName('Events');
MonkeyPatch('BD:EVENTS', Events.prototype).after('emit', (obj, args, retVal) => {
const eventId = args.length >= 3 ? args[2].id || -1 : -1;
if (eventId === this.ignoreMultiple) return;
this.ignoreMultiple = eventId;
if (obj.webSocket) this.wsc = obj.webSocket;
this.emit(...args);
});
/*
const orig = Events.prototype.emit;
Events.prototype.emit = function (...args) {
orig.call(this, ...args);
self.wsc = this;
self.emit(...args);
};
};*/
}
/**

View File

@ -24,3 +24,5 @@ export { default as EventHook } from './eventhook';
export { default as DiscordApi, Modules as DiscordApiModules } from './discordapi';
export { default as BdWebApi } from './bdwebapi';
export { default as Connectivity } from './connectivity';
export { default as Security } from './security';
export { default as Cache } from './cache';

View File

@ -0,0 +1,111 @@
/**
* BetterDiscord Security 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.
*/
import nodecrypto from 'node-crypto';
import aes256 from 'aes256';
export default class Security {
static encrypt(key, content, prefix = '') {
if (key instanceof Array || content instanceof Array) return this.deepEncrypt(key, content, prefix);
return `${prefix}${aes256.encrypt(key, content)}`;
}
static decrypt(key, content, prefix = '') {
if (key instanceof Array || content instanceof Array) {
return this.deepDecrypt(key, content, prefix);
}
return aes256.decrypt(key, content.replace(prefix, ''));
}
static deepEncrypt(keys, content, prefix = '') {
if (content && content instanceof Array) return this.deepEncryptContent(keys, content, prefix);
let encrypt = null;
for (const key of keys) {
if (encrypt === null) encrypt = this.encrypt(key, content, prefix);
else encrypt = this.encrypt(key, encrypt, prefix);
}
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 = '') {
if (content && content instanceof Array) return this.deepDecryptContent(keys, content, prefix);
let decrypt = null;
for (const key of keys.reverse()) {
if (decrypt === null) decrypt = this.decrypt(key, content, prefix);
else decrypt = this.decrypt(key, decrypt, prefix);
}
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();
});
}
static createECDH(curve = 'secp384r1') {
return nodecrypto.createECDH(curve);
}
static hash(algorithm, data, encoding) {
const hash = nodecrypto.createHash(algorithm);
hash.update(data);
return hash.digest(encoding);
}
static sha256(text) {
const hash = nodecrypto.createHash('sha256');
hash.update(text);
return hash.digest('base64');
}
static generateECDHKeys(ecdh) {
return ecdh.generateKeys('base64');
}
static getECDHPublicKey(ecdh) {
return ecdh.getPublicKey('base64');
}
static computeECDHSecret(ecdh, otherPublicKey) {
return ecdh.computeSecret(otherPublicKey, 'base64', 'base64');
}
}

View File

@ -56,6 +56,7 @@ const KnownModules = {
UserNameResolver: Filters.byProperties(['getName']),
UserNoteStore: Filters.byProperties(['getNote']),
UserNoteActions: Filters.byProperties(['updateNote']),
DraftActions: Filters.byProperties(['changeDraft']),
/* Emoji Store and Utils */
EmojiInfo: Filters.byProperties(['isEmojiDisabled']),

View File

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

View File

@ -129,3 +129,17 @@
transform: translate3d(4px, -4px, 0);
}
}
@keyframes bd-pulse {
0% {
opacity: 1;
}
50% {
opacity: .7;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,57 @@
.bd-formCollection {
display: flex;
flex-direction: column;
div:first-child {
flex: 1 1 auto;
}
.bd-collectionItem {
display: flex;
flex-grow: 1;
margin-top: 5px;
.bd-removeCollectionItem {
width: 20px;
flex: 0 1 auto;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-bottom: 30px;
&:hover {
svg {
fill: #FFF;
}
}
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
}
}
.bd-newCollectionItem {
display: flex;
cursor: pointer;
align-self: flex-end;
justify-content: center;
align-items: center;
margin-right: 2px;
svg {
width: 16px;
height: 16px;
fill: #ccc;
}
&:hover {
svg {
fill: #FFF;
}
}
}
}

View File

@ -0,0 +1,192 @@
.bd-e2eeTaContainer {
display: flex;
.bd-e2eeTaBtn {
padding: 10px;
display: flex;
flex: 1 1 auto;
flex-direction: row;
cursor: pointer;
opacity: .8;
transition: opacity .2s ease-in-out;
&:hover {
opacity: 1;
}
&.bd-e2eeLock {
&.bd-error {
fill: $colerr;
}
&.bd-ok {
fill: $colbdgreen;
}
&.bd-warn {
fill: $colwarn;
animation: bd-pulse 2s ease-in-out infinite;
}
}
}
.bd-taDivider {
background-color: rgba(255, 255, 255, 0.1);
box-sizing: border-box;
position: relative;
top: 10%;
width: 1px;
height: 37px;
margin-top: 5px;
}
.bd-e2eeLockContextMenu {
border: none;
.ctx-menu {
background: #23272A;
border-radius: 4px;
box-shadow: none;
padding: 0;
.bd-e2eeLockContextMenuOption {
background: #23272A;
color: #99AAB5;
padding: 8px 8px;
font-weight: 500;
cursor: default;
&:hover {
background: #000000;
}
}
}
}
}
.bd-e2eeMdContainer {
position: relative;
display: inline-block;
top: 4px;
.bd-e2eeMdBtn {
cursor: pointer;
&.bd-e2eeLock {
&.bd-error {
fill: $colerr;
}
&.bd-ok {
fill: $colbdgreen;
}
&.bd-warn {
fill: $colwarn;
}
&.bd-loading {
animation: bd-pulse 2s ease-in-out infinite;
}
}
}
}
.bd-e2eeMessageButtonWrap {
.bd-e2eeMessageButton {
fill: #99aab5;
margin-left: 6px;
margin-right: -2px;
opacity: .4;
transition: opacity .2s ease;
&:hover {
opacity: 1;
}
}
}
.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

@ -8,3 +8,5 @@
@import './updater.scss';
@import './window-preferences';
@import './kvp';
@import './collection';
@import './e2ee';

View File

@ -13,7 +13,8 @@
}
input[type="text"],
input[type="number"] {
input[type="number"],
input[type="password"] {
background: transparent;
border: none;
color: #b9bbbe;

View File

@ -7,3 +7,4 @@
@import './error-modal.scss';
@import './settings-modal.scss';
@import './permission-modal.scss';
@import './input-modal.scss';

View File

@ -0,0 +1,8 @@
.bd-inputModalBody {
display: flex;
flex-direction: column;
input {
margin-top: 5px;
}
}

View File

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

File diff suppressed because one or more lines are too long

View File

@ -13,8 +13,8 @@
<div slot="body" class="bd-modal-basic-body">{{ modal.text }}</div>
<div slot="footer" class="bd-modal-controls">
<div class="bd-flex-grow"></div>
<div class="bd-button" @click="modal.close">Cancel</div>
<div class="bd-button bd-ok" @click="() => { modal.confirm(); modal.close(); }">OK</div>
<div class="bd-button" @click="modal.close">{{ modal.cancelText || 'Cancel' }}</div>
<div class="bd-button bd-ok" @click="() => { modal.confirm(); modal.close(); }">{{ modal.confirmText || 'OK' }}</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,53 @@
/**
* BetterDiscord Input Modal Component
* 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.
*/
<template>
<Modal :class="['bd-modal-basic', {'bd-modal-out': modal.closing}]" :headerText="modal.title" @close="modal.close">
<div slot="body" class="bd-modal-basic-body bd-inputModalBody bd-form-textinput">
{{ modal.text }}
<input v-if="modal.password" ref="input" type="password" @keyup.stop="keyup" />
<input v-else ref="input" type="text" @keyup.stop="keyup"/>
</div>
<div slot="footer" class="bd-modal-controls">
<div class="bd-flex-grow"></div>
<div class="bd-button bd-ok" @click="() => { modal.confirm(value); modal.close(); }">OK</div>
</div>
</Modal>
</template>
<script>
// Imports
import { Modal } from '../../common';
export default {
data() {
return {
value: ''
}
},
props: ['modal'],
components: {
Modal
},
methods: {
keyup(e) {
if (e.key === 'Enter') {
this.modal.confirm(this.value);
this.modal.close();
return;
}
this.value = e.target.value;
}
},
mounted() {
this.$refs.input.focus();
}
}
</script>

View File

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

View File

@ -18,3 +18,5 @@ export { default as MiInfo } from './materialicons/Info.vue';
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';

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

@ -0,0 +1,27 @@
/**
* BetterDiscord Material Design 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 fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 11.9994,16.998C 13.1044,16.998 13.9994,16.1021 13.9994,14.998C 13.9994,13.894 13.1044,12.998 11.9994,12.998C 10.8954,12.998 9.9994,13.894 9.9994,14.998C 9.9994,16.1021 10.8954,16.998 11.9994,16.998 Z M 17.9994,7.99813C 19.1034,7.99813 19.9994,8.89413 19.9994,9.99813L 19.9994,19.9981C 19.9994,21.1021 19.1034,21.9981 17.9994,21.9981L 5.99938,21.9981C 4.89539,21.9981 3.99938,21.1021 3.99938,19.9981L 3.99938,9.99813C 3.99938,8.89413 4.89539,7.99813 5.99938,7.99813L 6.99938,7.99813L 6.99938,5.99813C 6.99938,3.23714 9.23838,0.998133 11.9994,0.998133C 14.7604,0.998133 16.9994,3.23714 16.9994,5.99813L 16.9994,7.99813L 17.9994,7.99813 Z M 12,3C 10.3431,3 9,4.34315 9,6L 9,8L 15,8L 15,6C 15,4.34315 13.6569,3 12,3 Z " />
</svg>
</span>
</template>
<script>
export default {
props: ['size']
}
</script>

View File

@ -16,6 +16,7 @@ import ConfirmModal from './components/bd/modals/ConfirmModal.vue';
import ErrorModal from './components/bd/modals/ErrorModal.vue';
import SettingsModal from './components/bd/modals/SettingsModal.vue';
import PermissionModal from './components/bd/modals/PermissionModal.vue';
import InputModal from './components/bd/modals/InputModal.vue';
let modals = 0;
@ -163,12 +164,12 @@ export default class Modals {
* @param {String} text A string that will be displayed in the modal body
* @return {Modal}
*/
static confirm(title, text) {
return this.add(this.createConfirmModal(title, text));
static confirm(title, text, confirmText, cancelText) {
return this.add(this.createConfirmModal(title, text, confirmText, cancelText));
}
static createConfirmModal(title, text) {
const modal = { title, text };
static createConfirmModal(title, text, confirmText, cancelText) {
const modal = { title, text, confirmText, cancelText };
modal.promise = new Promise((resolve, reject) => {
modal.confirm = () => resolve(true);
modal.beforeClose = () => reject();
@ -176,6 +177,19 @@ export default class Modals {
return new Modal(modal, ConfirmModal);
}
static input(title, text, password = false) {
return this.add(this.createInputModal(title, text, password));
}
static createInputModal(title, text, password = false) {
const modal = { title, text, password };
modal.promise = new Promise((resolve, reject) => {
modal.confirm = value => resolve(value);
modal.beforeClose = () => reject();
});
return new Modal(modal, InputModal);
}
/**
* Creates a new permissions modal and adds it to the open stack.
* The modal will have a promise property that will be set to a Promise object that is resolved or rejected if the user accepts the permissions or closes the modal.

View File

@ -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: {

View File

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

View File

@ -53,6 +53,45 @@ export class Utils {
return camelCased;
}
/**
* Finds a value, subobject, or array from a tree that matches a specific filter. Great for patching render functions.
* @param {object} tree React tree to look through. Can be a rendered object or an internal instance.
* @param {callable} searchFilter Filter function to check subobjects against.
*/
static findInReactTree(tree, searchFilter) {
return this.findInTree(tree, searchFilter, {walkable: ['props', 'children', 'child', 'sibling']});
}
/**
* Finds a value, subobject, or array from a tree that matches a specific filter.
* @param {object} tree Tree that should be walked
* @param {callable} searchFilter Filter to check against each object and subobject
* @param {object} options Additional options to customize the search
* @param {Array<string>|null} [options.walkable=null] Array of strings to use as keys that are allowed to be walked on. Null value indicates all keys are walkable
* @param {Array<string>} [options.ignore=[]] Array of strings to use as keys to exclude from the search, most helpful when `walkable = null`.
*/
static findInTree(tree, searchFilter, {walkable = null, ignore = []}) {
if (searchFilter(tree)) return tree;
if (typeof tree !== "object" || tree == null) return undefined;
let tempReturn = undefined;
if (tree instanceof Array) {
for (let value of tree) {
tempReturn = this.findInTree(value, searchFilter, {walkable, ignore});
if (typeof tempReturn != "undefined") return tempReturn;
}
}
else {
const toWalk = walkable == null ? Object.keys(tree) : walkable;
for (let key of toWalk) {
if (!tree.hasOwnProperty(key) || ignore.includes(key)) continue;
tempReturn = this.findInTree(tree[key], searchFilter, {walkable, ignore});
if (typeof tempReturn != "undefined") return tempReturn;
}
}
return tempReturn;
}
/**
* Checks if two or more values contain the same data.
* @param {Any} ...value The value to compare
@ -176,6 +215,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 {

View File

@ -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();

30
package-lock.json generated
View File

@ -4796,9 +4796,9 @@
"dev": true,
"optional": true,
"requires": {
"asynckit": "0.4.0",
"combined-stream": "1.0.5",
"mime-types": "2.1.15"
"asynckit": "^0.4.0",
"combined-stream": "^1.0.5",
"mime-types": "^2.1.12"
}
},
"fs.realpath": {
@ -4901,8 +4901,8 @@
"dev": true,
"optional": true,
"requires": {
"ajv": "4.11.8",
"har-schema": "1.0.5"
"ajv": "^4.9.1",
"har-schema": "^1.0.5"
}
},
"has-unicode": {
@ -4971,7 +4971,7 @@
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"dev": true,
"requires": {
"number-is-nan": "1.0.1"
"number-is-nan": "^1.0.0"
}
},
"is-typedarray": {
@ -5076,7 +5076,7 @@
"integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=",
"dev": true,
"requires": {
"mime-db": "1.27.0"
"mime-db": "~1.27.0"
}
},
"minimatch": {
@ -5085,7 +5085,7 @@
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "1.1.7"
"brace-expansion": "^1.1.7"
}
},
"minimist": {
@ -5137,8 +5137,8 @@
"dev": true,
"optional": true,
"requires": {
"abbrev": "1.1.0",
"osenv": "0.1.4"
"abbrev": "1",
"osenv": "^0.1.4"
}
},
"npmlog": {
@ -5204,8 +5204,8 @@
"dev": true,
"optional": true,
"requires": {
"os-homedir": "1.0.2",
"os-tmpdir": "1.0.2"
"os-homedir": "^1.0.0",
"os-tmpdir": "^1.0.0"
}
},
"path-is-absolute": {
@ -13459,6 +13459,12 @@
"tinycolor2": "1.4.1"
}
},
"vue-context-menu": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/vue-context-menu/-/vue-context-menu-2.0.6.tgz",
"integrity": "sha512-wTyyjWbrGq/Rc1iivHl9ryI0IcsodRgg46CAGCm6knADDsTj5qJzo2pPDfi6y2nQreg8llJC2lGGts88tMX1sQ==",
"dev": true
},
"vue-eslint-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz",

View File

@ -61,6 +61,7 @@
"vue": "^2.5.17",
"vue-codemirror": "^4.0.5",
"vue-color": "^2.4.6",
"vue-context-menu": "^2.0.6",
"vue-loader": "^13.7.2",
"vue-material-design-icons": "^1.6.0",
"vue-template-compiler": "^2.5.17",