diff --git a/.gitignore b/.gitignore index f918f96..1c3273d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules +npm-debug.log src/public/main.js src/.secret diff --git a/readme.md b/readme.md index 953bccd..253f612 100644 --- a/readme.md +++ b/readme.md @@ -46,7 +46,15 @@ Group chats work the same way because in step 5 we encrypt keys with everyone's Darkwire does not provide any guarantee that the person you're communicating with is who you think they are. Authentication functionality may be incorporated in future versions. -### Sockets & Server +## File Transfer + +Files are not transferred over the wire-only the file name and extension. Darkwire encodes documents into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are. + +1. When a file is "uploaded", the document is encoded on the client and the server recieves the encrypted base64 string. +2. The server sends the encrypted base64 string to clients in the same chat room. +3. Clients recieving the encrypted base64 string then decrypts the string, then decodes the base64 string using [atob](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/atob). + +## Sockets & Server Darkwire uses [socket.io](http://socket.io) to transmit encrypted information using secure [WebSockets](https://en.wikipedia.org/wiki/WebSocket) (WSS). diff --git a/src/app.js b/src/app.js index 41bc917..343660a 100644 --- a/src/app.js +++ b/src/app.js @@ -10,7 +10,9 @@ import fs from 'fs'; import Room from './room'; -const $PORT = 3000; +const $CONFIG = { + port: process.env.port || 3000 +}; const app = express(); const server = http.createServer(app); @@ -54,6 +56,6 @@ app.get('/:roomId', (req, res) => { return res.redirect('/'); }); -server.listen($PORT, () => { - console.log(`darkwire is online on port ${$PORT}.`); +server.listen($CONFIG.port, () => { + console.log(`darkwire is online on port ${$CONFIG.port}.`); }); diff --git a/src/js/darkwire.js b/src/js/darkwire.js new file mode 100644 index 0000000..1b13949 --- /dev/null +++ b/src/js/darkwire.js @@ -0,0 +1,226 @@ +import _ from 'underscore'; +import AudioHandler from './audio'; +import CryptoUtil from './crypto'; + +export default class Darkwire { + constructor() { + this._audio = new AudioHandler(); + this._cryptoUtil = new CryptoUtil(); + this._myUserId = false; + this._connected = false; + this._users = []; + this._keys = {}; + } + + get keys() { + return this._keys; + } + + set keys(keys) { + this._keys = keys; + return this._keys; + } + + get connected() { + return this._connected; + } + + set connected(state) { + this._connected = state; + return this._connected; + } + + get users() { + return this._users; + } + + get audio() { + return this._audio; + } + + addUser(data) { + let importKeysPromises = []; + // Import all user keys if not already there + _.each(data.users, (user) => { + if (!_.findWhere(this._users, {id: user.id})) { + let promise = new Promise((resolve, reject) => { + let currentUser = user; + Promise.all([ + this._cryptoUtil.importPrimaryKey(currentUser.publicKey, 'spki') + ]) + .then((keys) => { + this._users.push({ + id: currentUser.id, + username: currentUser.username, + publicKey: keys[0] + }); + resolve(); + }); + }); + + importKeysPromises.push(promise); + } + }); + + if (!this._myUserId) { + // Set my id if not already set + let me = _.findWhere(data.users, {username: username}); + this._myUserId = me.id; + } + + return importKeysPromises; + } + + removeUser(data) { + this._users = _.without(this._users, _.findWhere(this._users, {id: data.id})); + return this._users; + } + + encodeMessage(message, messageType) { + // Don't send unless other users exist + return new Promise((resolve, reject) => { + // if (this._users.length <= 1) { + // console.log('rejected:' + this._users); + // reject(); + // return; + // }; + + // if there is a non-empty message and a socket connection + if (message && this._connected) { + let vector = this._cryptoUtil.crypto.getRandomValues(new Uint8Array(16)); + + let secretKey = null; + let secretKeys = null; + let messageData = null; + let signature = null; + let signingKey = null; + let encryptedMessageData = null; + + // Generate new secret key and vector for each message + this._cryptoUtil.createSecretKey() + .then((key) => { + secretKey = key; + return this._cryptoUtil.createSigningKey(); + }) + .then((key) => { + signingKey = key; + // Generate secretKey and encrypt with each user's public key + let promises = []; + _.each(this._users, (user) => { + // If not me + if (user.username !== window.username) { + let promise = new Promise((res, rej) => { + let thisUser = user; + + let secretKeyStr; + + // Export secret key + this._cryptoUtil.exportKey(secretKey, 'raw') + .then((data) => { + return this._cryptoUtil.encryptSecretKey(data, thisUser.publicKey); + }) + .then((encryptedSecretKey) => { + let encData = new Uint8Array(encryptedSecretKey); + secretKeyStr = this._cryptoUtil.convertArrayBufferViewToString(encData); + // Export HMAC signing key + return this._cryptoUtil.exportKey(signingKey, 'raw'); + }) + .then((data) => { + // Encrypt signing key with user's public key + return this._cryptoUtil.encryptSigningKey(data, thisUser.publicKey); + }) + .then((encryptedSigningKey) => { + let encData = new Uint8Array(encryptedSigningKey); + var str = this._cryptoUtil.convertArrayBufferViewToString(encData); + res({ + id: thisUser.id, + secretKey: secretKeyStr, + encryptedSigningKey: str + }); + }); + }); + promises.push(promise); + } + }); + return Promise.all(promises); + }) + .then((data) => { + secretKeys = data; + messageData = this._cryptoUtil.convertStringToArrayBufferView(message); + return this._cryptoUtil.signKey(messageData, signingKey); + }) + .then((data) => { + signature = data; + return this._cryptoUtil.encryptMessage(messageData, secretKey, vector); + }) + .then((data) => { + encryptedMessageData = data; + let vct = this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(vector)); + let sig = this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(signature)); + let msg = this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(encryptedMessageData)); + + resolve({ + message: msg, + vector: vct, + messageType: messageType, + secretKeys: secretKeys, + signature: sig + }); + }); + } + + }); + } + + decodeMessage(data) { + return new Promise((resolve, reject) => { + let message = data.message; + let messageData = this._cryptoUtil.convertStringToArrayBufferView(message); + let username = data.username; + let senderId = data.id; + let vector = data.vector; + let vectorData = this._cryptoUtil.convertStringToArrayBufferView(vector); + let secretKeys = data.secretKeys; + let decryptedMessageData; + let decryptedMessage; + + let mySecretKey = _.find(secretKeys, (key) => { + return key.id === this._myUserId; + }); + let signature = data.signature; + let signatureData = this._cryptoUtil.convertStringToArrayBufferView(signature); + let secretKeyArrayBuffer = this._cryptoUtil.convertStringToArrayBufferView(mySecretKey.secretKey); + let signingKeyArrayBuffer = this._cryptoUtil.convertStringToArrayBufferView(mySecretKey.encryptedSigningKey); + + this._cryptoUtil.decryptSecretKey(secretKeyArrayBuffer, this._keys.private) + .then((data) => { + return this._cryptoUtil.importSecretKey(new Uint8Array(data), 'raw'); + }) + .then((data) => { + let secretKey = data; + return this._cryptoUtil.decryptMessage(messageData, secretKey, vectorData); + }) + .then((data) => { + decryptedMessageData = data; + decryptedMessage = this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(data)); + return this._cryptoUtil.decryptSigningKey(signingKeyArrayBuffer, this._keys.private); + }) + .then((data) => { + return this._cryptoUtil.importSigningKey(new Uint8Array(data), 'raw'); + }) + .then((data) => { + let signingKey = data; + return this._cryptoUtil.verifyKey(signatureData, decryptedMessageData, signingKey); + }) + .then((bool) => { + if (bool) { + resolve({ + username: username, + message: decryptedMessage, + messageType: data.messageType + }); + } + }); + }); + } +} diff --git a/src/js/fileHandler.js b/src/js/fileHandler.js new file mode 100644 index 0000000..509d955 --- /dev/null +++ b/src/js/fileHandler.js @@ -0,0 +1,62 @@ +export default class FileHandler { + constructor(darkwire, socket) { + if (window.File && window.FileReader && window.FileList && window.Blob && window.btoa && window.atob) { + this._isSupported = true; + this.darkwire = darkwire; + this.socket = socket; + this.listen(); + } else { + this._isSupported = false; + } + } + + get isSupported() { + return this._isSupported; + } + + encodeFile(event) { + const file = event.target.files && event.target.files[0]; + + if (file) { + + // let encodedFile = { + // fileName: file.name, + // fileSize: file.fileSize, + // base64: null + // }; + + // Support for only 1MB + if (file.size > 1000000) { + console.log(file); + alert('Max filesize is 1MB.'); + return false; + } + + const reader = new FileReader(); + + reader.onload = (readerEvent) => { + const base64 = window.btoa(readerEvent.target.result); + this.darkwire.encodeMessage(base64, 'file').then((socketData) => { + this.socket.emit('new message', socketData); + }); + }; + + reader.readAsBinaryString(file); + } + + return false; + } + + decodeFile(base64) { + return window.atob(base64); + } + + listen() { + // browser API + document.getElementById('fileInput').addEventListener('change', this.encodeFile.bind(this), false); + + // darkwire + + return this; + } +} diff --git a/src/js/main.js b/src/js/main.js index d308ed9..a890142 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,15 +1,13 @@ -import AudioHandler from './audio'; -import CryptoUtil from './crypto'; +import Darkwire from './darkwire'; import WindowHandler from './window'; +import CryptoUtil from './crypto'; let fs = window.RequestFileSystem || window.webkitRequestFileSystem; $(function() { - const audio = new AudioHandler(); + const darkwire = new Darkwire(); const cryptoUtil = new CryptoUtil(); - const windowHandler = new WindowHandler(); - let newMessages = 0; let FADE_TIME = 150; // ms let TYPING_TIMER_LENGTH = 400; // ms @@ -28,19 +26,12 @@ $(function() { let $chatPage = $('.chat.page'); // The chatroom page - let users = []; - - // Prompt for setting a username let username; - let myUserId; - let connected = false; let typing = false; let lastTypingTime; let roomId = window.location.pathname.length ? window.location.pathname : null; - let keys = {}; - if (!roomId) { return; } $('input.share-text').val(document.location.protocol + '//' + document.location.host + roomId); @@ -52,6 +43,7 @@ $(function() { }); let socket = io(roomId); + const windowHandler = new WindowHandler(darkwire, socket); FastClick.attach(document.body); @@ -83,7 +75,7 @@ $(function() { cryptoUtil.createPrimaryKeys() ]) .then(function(data) { - keys = { + darkwire.keys = { public: data[0].publicKey, private: data[0].privateKey }; @@ -130,10 +122,20 @@ $(function() { let $usernameDiv = $('') .text(data.username) .css('color', getUsernameColor(data.username)); - let $messageBodyDiv = $('') - .html(data.message); + let $messageBodyDiv = $(''); + // TODO: Ask client if accept/reject attachment + // If reject, destroy object in memory + // If accept, render image or content dispose + if (dataType.file) { + let image = new Image(); + image.src = `data:image/png;base64,${data.message}`; + $messageBodyDiv.html(image); + } else { + $messageBodyDiv.html(data.message); + } let typingClass = data.typing ? 'typing' : ''; + let $messageDiv = $('
  • ') .data('username', data.username) .addClass(typingClass) @@ -197,7 +199,7 @@ $(function() { // Updates the typing event function updateTyping() { - if (connected) { + if (darkwire.connected) { if (!typing) { typing = true; socket.emit('typing'); @@ -239,7 +241,7 @@ $(function() { $window.keydown(function(event) { // When the client hits ENTER on their keyboard and chat message input is focused if (event.which === 13 && $('.inputMessage').is(':focus')) { - sendMessage(); + handleMessageSending(); socket.emit('stop typing'); typing = false; } @@ -280,47 +282,16 @@ $(function() { // Whenever the server emits 'login', log the login message socket.on('user joined', function(data) { - connected = true; + darkwire.connected = true; addParticipantsMessage(data); - - let importKeysPromises = []; - - // Import all user keys if not already there - _.each(data.users, function(user) { - if (!_.findWhere(users, {id: user.id})) { - let promise = new Promise(function(resolve, reject) { - let currentUser = user; - Promise.all([ - cryptoUtil.importPrimaryKey(currentUser.publicKey, 'spki') - ]) - .then(function(keys) { - users.push({ - id: currentUser.id, - username: currentUser.username, - publicKey: keys[0] - }); - resolve(); - }); - }); - importKeysPromises.push(promise); - } - }); - - if (!myUserId) { - // Set my id if not already set - let me = _.findWhere(data.users, {username: username}); - myUserId = me.id; - } - - Promise.all(importKeysPromises) - .then(function() { + let importKeysPromises = darkwire.addUser(data); + Promise.all(importKeysPromises).then(() => { // All users' keys have been imported if (data.numUsers === 1) { $('#first-modal').modal('show'); } log(data.username + ' joined'); - renderParticipantsList(); }); @@ -479,6 +450,28 @@ $(function() { }); } }); + + }); + + // Whenever the server emits 'new message', update the chat body + socket.on('new message', function(data) { + darkwire.decodeMessage(data).then((data) => { + if (!windowHandler.isActive) { + windowHandler.notifyFavicon(); + darkwire.audio.play(); + } + if (data.messageType === 'file') { + // let file = windowHandler.fileHandler.decodeFile(data.message); + // let chatMessage = { + // username: data.username, + // message: file + // } + addChatMessage(data, false, {file: true}); + } else { + addChatMessage(data); + } + }); + }); // Whenever the server emits 'user left', log it in the chat body @@ -487,7 +480,7 @@ $(function() { addParticipantsMessage(data); removeChatTyping(data); - users = _.without(users, _.findWhere(users, {id: data.id})); + darkwire.removeUser(data); renderParticipantsList(); }); @@ -522,7 +515,7 @@ $(function() { function renderParticipantsList() { $('#participants-modal ul.users').empty(); - _.each(users, function(user) { + _.each(darkwire.users, function(user) { let li; if (user.username === window.username) { // User is me @@ -536,7 +529,7 @@ $(function() { } $('#send-message-btn').click(function() { - sendMessage(); + handleMessageSending(); socket.emit('stop typing'); typing = false; }); @@ -548,7 +541,24 @@ $(function() { let audioSwitch = $('input.bs-switch').bootstrapSwitch(); audioSwitch.on('switchChange.bootstrapSwitch', function(event, state) { - audio.soundEnabled = state; + darkwire.audio.soundEnabled = state; }); + function handleMessageSending() { + let message = $inputMessage; + let cleanedMessage = cleanInput(message.val()); + // Prevent markup from being injected into the message + darkwire.encodeMessage(cleanedMessage, 'chat').then((socketData) => { + message.val(''); + $('#send-message-btn').removeClass('active'); + addChatMessage({ + username: username, + message: cleanedMessage + }); + socket.emit('new message', socketData); + }).catch((err) => { + console.log(err); + }); + } + }); diff --git a/src/js/window.js b/src/js/window.js index 784e4d4..db196c9 100644 --- a/src/js/window.js +++ b/src/js/window.js @@ -1,6 +1,9 @@ +import FileHandler from './fileHandler'; + export default class WindowHandler { - constructor() { + constructor(darkwire, socket) { this._isActive = false; + this.fileHandler = new FileHandler(darkwire, socket); this.newMessages = 0; this.favicon = new Favico({ @@ -8,6 +11,7 @@ export default class WindowHandler { type: 'rectangle' }); + this.enableFileTransfer(); this.bindEvents(); } @@ -25,6 +29,15 @@ export default class WindowHandler { this.favicon.badge(this.newMessages); } + enableFileTransfer() { + if (this.fileHandler.isSupported) { + $('#send-file').click((e) => { + e.preventDefault(); + $('#fileInput').trigger('click'); + }); + } + } + bindEvents() { window.onfocus = () => { this._isActive = true; diff --git a/src/public/style.css b/src/public/style.css index eb9beff..873608f 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -222,11 +222,12 @@ input { cursor: pointer; } +/* html.no-touchevents .chat #input-icons { display: none; -} +}*/ -.chat #input-icons #send-message-btn { +.chat #input-icons #send-message-btn, .chat #input-icons #send-file { font-size: 25px; opacity: 0.3; color: white; @@ -291,3 +292,7 @@ html.no-touchevents .chat #input-icons { background: #2a9fd6 !important; border-color: #2a9fd6 !important; } + +#fileInput { + display: none; +} diff --git a/src/room.js b/src/room.js index e10ac59..d8ea3bc 100644 --- a/src/room.js +++ b/src/room.js @@ -22,6 +22,8 @@ class Room { username: socket.username, id: socket.user.id, message: data.message, + messageType: data.messageType, + data: data.data, vector: data.vector, secretKeys: data.secretKeys, signature: data.signature diff --git a/src/views/index.mustache b/src/views/index.mustache index 1d8c8ae..4d9f076 100644 --- a/src/views/index.mustache +++ b/src/views/index.mustache @@ -55,6 +55,8 @@
    + +