Merge pull request #220 from JsSucks/security-encrypted-images

Security encrypted images
This commit is contained in:
Alexei Stukov 2018-08-13 14:34:59 +03:00 committed by GitHub
commit f5cbd8d491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 440 additions and 44 deletions

View File

@ -8,32 +8,33 @@
* LICENSE file in the root directory of this source tree.
*/
import { Settings } from 'modules';
import { Settings, Cache } from 'modules';
import BuiltinModule from './BuiltinModule';
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi } from 'modules';
import { WebpackModules, ReactComponents, MonkeyPatch, Patcher, DiscordApi, Security } from 'modules';
import { VueInjector, Reflection } from 'ui';
import { ClientLogger as Logger } from 'common';
import { request } from 'vendor';
import { Utils } from 'common';
import E2EEComponent from './E2EEComponent.vue';
import E2EEMessageButton from './E2EEMessageButton.vue';
import aes256 from 'aes256';
const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, "g");
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, "g");
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`);
let seed = Math.random().toString(36).replace(/[^a-z]+/g, '');
const TEMP_KEY = 'temporarymasterkey';
let seed;
export default new class E2EE extends BuiltinModule {
constructor() {
super();
this.master = this.encrypt(seed, 'temporarymasterkey');
this.encryptNewMessages = true;
}
setMaster(key) {
seed = Math.random().toString(36).replace(/[^a-z]+/g, '');
const newMaster = this.encrypt(seed, key);
seed = Security.randomBytes();
const newMaster = Security.encrypt(seed, key);
// TODO re-encrypt everything with new master
return (this.master = newMaster);
}
@ -47,11 +48,28 @@ export default new class E2EE extends BuiltinModule {
}
encrypt(key, content, prefix = '') {
return prefix + aes256.encrypt(key, content);
if (!key) {
// Encrypt something with master
return Security.encrypt(Security.decrypt(seed, this.master), content);
}
if (!content) {
// Get key for current channel and encrypt
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return 'nokey';
return Security.encrypt(Security.decrypt(seed, [this.master, haveKey]), key);
}
return prefix + Security.encrypt(key, content);
}
decrypt(key, content, prefix = '') {
return aes256.decrypt(key, content.replace(prefix, ''));
return Security.decrypt(key, content, prefix);
}
async createHmac(data) {
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return null;
return Security.createHmac(Security.decrypt(seed, [this.master, haveKey]), data);
}
getKey(channelId) {
@ -61,6 +79,9 @@ export default new class E2EE extends BuiltinModule {
}
async enabled(e) {
seed = Security.randomBytes();
// TODO Input modal for key
this.master = Security.encrypt(seed, TEMP_KEY);
this.patchDispatcher();
this.patchMessageContent();
const selector = '.' + WebpackModules.getClassName('channelTextArea', 'emojiButton');
@ -107,6 +128,8 @@ export default new class E2EE extends BuiltinModule {
const MessageContent = await ReactComponents.getComponent('MessageContent', { selector });
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).before('render', this.beforeRenderMessageContent.bind(this));
MonkeyPatch('BD:E2EE', MessageContent.component.prototype).after('render', this.renderMessageContent.bind(this));
const ImageWrapper = await ReactComponents.getComponent('ImageWrapper', { selector: '.' + WebpackModules.getClassName('imageWrapper') });
MonkeyPatch('BD:E2EE', ImageWrapper.component.prototype).before('render', this.beforeRenderImageWrapper.bind(this));
}
beforeRenderMessageContent(component) {
@ -125,7 +148,7 @@ export default new class E2EE extends BuiltinModule {
if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string
let decrypt;
try {
decrypt = this.decrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.message.content);
decrypt = Security.decrypt(seed, [this.master, key, component.props.message.content]);
} catch (err) { return } // Ignore errors such as non empty
component.props.message.bd_encrypted = true; // signal as encrypted
@ -154,10 +177,77 @@ export default new class E2EE extends BuiltinModule {
renderMessageContent(component, args, retVal) {
if (!component.props.message.bd_encrypted) return;
try {
retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton));
} catch (err) {
Logger.err('E2EE', err.message);
}
}
retVal.props.children[0].props.children.props.children.props.children.unshift(VueInjector.createReactElement(E2EEMessageButton, {
message: component.props.message
}));
beforeRenderImageWrapper(component, args, retVal) {
if (!component.props || !component.props.src) return;
if (component.props.decrypting) return;
component.props.decrypting = true;
const src = component.props.original || component.props.src.split('?')[0];
if (!src.includes('bde2ee')) return;
component.props.className = 'bd-encryptedImage';
const haveKey = this.getKey(DiscordApi.currentChannel.id);
if (!haveKey) return;
const cached = Cache.find('e2ee:images', item => item.src === src);
if (cached) {
if (cached.invalidKey) { // TODO If key has changed we should recheck all with invalid key
component.props.className = 'bd-encryptedImage bd-encryptedImageBadKey';
component.props.readyState = 'READY';
return;
}
Logger.info('E2EE', 'Returning encrypted image from cache');
try {
const decrypt = Security.decrypt(seed, [this.master, haveKey, cached.image]);
component.props.className = 'bd-decryptedImage';
component.props.src = component.props.original = 'data:;base64,' + decrypt;
} catch (err) { return } finally { component.props.readyState = 'READY' }
return;
}
component.props.readyState = 'LOADING';
Logger.info('E2EE', 'Decrypting image: ' + src);
request.get(src, { encoding: 'binary' }).then(res => {
(async () => {
const arr = new Uint8Array(new ArrayBuffer(res.length));
for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i);
const aobindex = Utils.aobscan(arr, [73, 69, 78, 68]) + 8;
const sliced = arr.slice(aobindex);
const image = new TextDecoder().decode(sliced);
const hmac = image.slice(-64);
const data = image.slice(0, -64);
const validateHmac = await this.createHmac(data);
if (hmac !== validateHmac) {
Cache.push('e2ee:images', { src, invalidKey: true });
if (component && component.props) {
component.props.decrypting = false;
component.forceUpdate();
}
return;
}
Cache.push('e2ee:images', { src, image: data });
if (!component || !component.props) {
Logger.warn('E2EE', 'Component seems to be gone');
return;
}
component.props.decrypting = false;
component.forceUpdate();
})();
}).catch(err => {
console.log('request error', err);
});
}
patchChannelTextArea(cta) {
@ -177,7 +267,7 @@ export default new class E2EE extends BuiltinModule {
handleChannelTextAreaSubmit(component, args, retVal) {
const key = this.getKey(DiscordApi.currentChannel.id);
if (!this.encryptNewMessages || !key) return;
component.props.value = this.encrypt(this.decrypt(this.decrypt(seed, this.master), key), component.props.value, '$:');
component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:');
}
async disabled(e) {

View File

@ -10,30 +10,43 @@
<template>
<div class="bd-e2eeTaContainer">
<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" @click="E2EE.encryptNewMessages = true">
<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>
<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>
</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 } from 'modules';
import { MiLock } from '../ui/components/common/MaterialIcon';
import { DiscordApi, Security } from 'modules';
import { MiLock, MiPlus, MiImagePlus } from '../ui/components/common/MaterialIcon';
import { Toasts } from 'ui';
export default {
components: { MiLock },
components: { MiLock, MiPlus, MiImagePlus },
data() {
return {
E2EE,
@ -41,7 +54,36 @@
error: null
};
},
methods: {},
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');
}
},
mounted() {
if (!E2EE.master) {
this.error = 'No master key set!';

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

@ -10,7 +10,7 @@
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 electron from 'electron';
@ -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

@ -25,3 +25,4 @@ export { default as DiscordApi, Modules as DiscordApiModules } from './discordap
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

@ -8,21 +8,25 @@
* 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) 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)}`;
}
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, ''));
}
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);
@ -31,7 +35,17 @@ export default class Security {
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);
@ -40,4 +54,30 @@ export default class Security {
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';
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

@ -16,7 +16,7 @@
&.bd-e2eeLock {
&.bd-error {
fill: #cc3e3e;
fill: $colerr;
}
&.bd-ok {
@ -24,7 +24,7 @@
}
&.bd-warn {
fill: #ccab3e;
fill: $colwarn;
animation: bd-pulse 2s ease-in-out infinite;
}
}
@ -51,7 +51,7 @@
&.bd-e2eeLock {
&.bd-error {
fill: #cc3e3e;
fill: $colerr;
}
&.bd-ok {
@ -59,7 +59,7 @@
}
&.bd-warn {
fill: #ccab3e;
fill: $colwarn;
}
&.bd-loading {
@ -82,3 +82,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

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

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

@ -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';

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

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

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