#!/usr/bin/env nodejs 'use strict'; const crypto = require('crypto'); const ws = require('ws'); const config = { rsaPrivate: crypto.createPrivateKey({ key: Buffer.from('MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDMqxjzmpko9umpyWFoRjP0JcdSpPBd7IJifI5VB+sNjaFqjmQCqmVTU0qn3ChyDzV7UQIVMybvlAUmEMv9n8vqDvprWxoUZ3R+Wlnarnke10ht4eaFVl3YL4IN+x++hS0uIVK8q2VvFoTSE6fely6tUU7pF7xAIHs9TtF/iN2/KXB4WNC9/vyg8b2sjW7Ok/Blh92Hbcdtgin9T882KUiMT102KvtrjN8a+gWxfOlLd4vozdmkcEbtOpRkh7EgkMf4KaelROzXePjKct8K/YGphy+s6W8aVLR8Px7EmOaiDt0JaByJY+1wjYb5LrQQ3qi4qzSWpSb2QNiW2cjBZIZpAgMBAAECggEACHpe7HpfNU/zEm4t24aq8MZe2RjNfyqEpSVBNRsAnN1czh/rWyPpUhgNa+ImB8GDBuJuamflyT9Rrke8V3t21DF+JJPPwwAdFn3SIomfKUykKq8UoDnZZdLj+YXJRRhZrINDXN7hswYVBum7BrCvBY1sbiHmUaa2zC2XZofKHbi78iAldZW/N7HxCmWv8NlxU76X6EBza6uQPwiz3aWr3lbZAE9BQVbU1T9fyW9s11auEunciP6eTrv7IYb1318SAJ5oPj2bDqMS2qr4BpdwMHw0ny6OfNJJfPlbV66J6FfM69sgxNKIgPlBW4tcYsDOXZf2+9uOLclKZJeEg9u8oQKBgQD4KDberLO+WdHs1ctgBEvQ1cdbTxcvXmlwuPo4gMOW7y6xPaC6hQil+ngnLTOTbIbWkRfYDJ0MmkUdCLmaoh6d3VecWOFny3OjpdZmlu/2DWdwLDBbBm4QrjNIEWVL682+Yx3CeiEooakD01NEVfAXWVGqvjkQRXcC1vkTcOh1UQKBgQDTIwZs9yFAW56UVlj54DGdvYq+Wcfm//yPP0tLwShZs7EnZO/a9CJ3Hx7T5wWUIYOKi5r+hLD9/HV3l/Wn8YU/nF+HvIZ6D83Wi+jbytXn15cA4v1uAQPd/JkFxoVIclbIoi8KT3NNZoyYAcpf8qUY4/mCsgPWQG7pxpTbkImZmQKBgAUhd7j04yZ9XuzBotnSXOrtcJi/suAE2LM+79V5PBID0fhSKN+SXpJn8lIFCjrzFHqdN1v9LsYS7njiNEwxK+bhq60M3WQy/aoVhLBMUJvKvSfBkiJfqKjpoR/NVEXUk0VrcTMTKVpYtVDh7qfMCxHAhAK+uhlPBl3lqfKv8+5xAoGAJBreM24DQqUoNgeaCYjcEzRhcHGb7JWj078oGhk61DQVPD5XgeKzrhklRWlBG/arLtr2s0Vvms88KypSONjlR15vO5/ygxcGxQSB2mwSLiZAzUcDvXbw+VoDtOggzKW4z3BkiOfTOM4rhqbvu36b2ujJNx9H5rB4BwQNWoqZ7SkCgYA0W1tXV92Uh+h6DEBciPChfiCjJctNpnvDUx/QZGYm5sIp6I95dF0Qu2ufQNle2zzcPz/VRnei3zkF5ZQyBcBNvd65wqGoS0bub84Qi6BXJUmIXt72HvRsRH+ztzD8gJKWBES6kS9HucdGWCmq8WOPrWBbLRmKqrLZphSqu7CgCg==', 'base64'), format: 'der', type: 'pkcs8' }), wsHost: '127.0.0.1', wsPort: 8088, seenTimeout: 60000, debug: false }; // Create websocket server const wss = new ws.Server({ host: config.wsHost, port: config.wsPort, perMessageDeflate: false }); console.log('server started', config.wsHost, config.wsPort); // Listen for client connections var clients = {}; var channels = {}; wss.on('connection', ( connection ) => { if ( ! connection ) { return; } // Terminate clients have not seen for a while const seenThreshold = ( getTime() - config.seenTimeout ); for ( const clientId in clients ) { if ( clients[clientId].seen < seenThreshold ) { try { logEvent('connection-seen', clientId, 'debug'); clients[clientId].connection.terminate(); } catch ( error ) { logEvent('connection-seen', error, 'error'); } } } // Generate client id // Ultimately make sure that it does not collide const clientId = generateClientId(); if ( ! clientId || clients[clientId] ) { closeConnection(connection); return; } logEvent('connection', clientId, 'debug'); // Add client to client list clients[clientId] = { connection: connection, seen: getTime(), key: null, channel: null }; /* ============ ON MESSAGE ============ */ connection.on('message', ( message ) => { if ( ! isString(message) || ! clients[clientId] ) { return; } // Update seen clients[clientId].seen = getTime(); // Respond to ping requests if ( message === 'ping' ) { sendMessage(connection, 'pong'); return; } logEvent('message', [ clientId, message ], 'debug'); // Receive client ecdh public key if ( ! clients[clientId].shared && message.length < 2048 ) { // Generate client ecdh keys // Set client ecdh shared key (just 256 bits required from the possible 384) // Send rsa signed ecdh public key to client try { const keys = crypto.createECDH('secp384r1'); keys.generateKeys(); const publicKey = keys.getPublicKey(); const signature = crypto.sign('sha256', publicKey, { key: config.rsaPrivate, padding: crypto.constants.RSA_PKCS1_PADDING, dsaEncoding: 'ieee-p1363' }); clients[clientId].shared = keys.computeSecret(message, 'hex', null).slice(8, 40); sendMessage(connection, publicKey.toString('hex') + '|' + signature.toString('base64')); } catch ( error ) { logEvent('message-key', [ clientId, error ], 'error'); closeConnection(connection); } return; } // Receive encrypted message from client if ( clients[clientId].shared && message.length <= ( 8 * 1024 * 1024 ) ) { const decrypted = decryptMessage(message, clients[clientId].shared); logEvent('message-decrypted', [ clientId, decrypted ], 'debug'); if ( ! isObject(decrypted) || ! isString(decrypted.a) ) { return; } // Join channel if ( decrypted.a === 'j' && isString(decrypted.p) && ! clients[clientId].channel ) { try { const channel = decrypted.p; // Set client channel clients[clientId].channel = channel; // Add client to channel if ( ! channels[channel] ) { channels[channel] = [ clientId ]; } else { channels[channel].push(clientId); } // Send channel client list (except themselves) to all clients in the channel const members = channels[channel]; for ( const member of members ) { const client = clients[member]; if ( isClientInChannel(client, channel) ) { sendMessage(client.connection, encryptMessage({ a: 'l', p: members.filter(( value ) => { return( value !== member ? true : false ); }) }, client.shared)); } } } catch ( error ) { logEvent('message-join', [ clientId, error ], 'error'); } return; } // Message client if ( decrypted.a === 'c' && isString(decrypted.p) && isString(decrypted.c) && clients[clientId].channel ) { try { const channel = clients[clientId].channel; const client = clients[decrypted.c]; if ( isClientInChannel(client, channel) ) { sendMessage(client.connection, encryptMessage({ a: 'c', p: decrypted.p, c: clientId }, client.shared)); } } catch ( error ) { logEvent('message-client', [ clientId, error ], 'error'); } return; } // Message whole channel if ( decrypted.a === 'w' && isObject(decrypted.p) && clients[clientId].channel ) { try { const channel = clients[clientId].channel; for ( const member in decrypted.p ) { const client = clients[member]; if ( isString(decrypted.p[member]) && isClientInChannel(client, channel) ) { sendMessage(client.connection, encryptMessage({ a: 'c', p: decrypted.p[member], c: clientId }, client.shared)); } } } catch ( error ) { logEvent('message-channel', [ clientId, error ], 'error'); } return; } } }); /* ========== ON CLOSE ========== */ connection.on('close', ( event ) => { logEvent('close', [ clientId, event ], 'debug'); // Remove client from channel const channel = clients[clientId].channel; if ( channel && channels[channel] ) { channels[channel].splice(channels[channel].indexOf(clientId), 1); if ( channels[channel].length === 0 ) { // Remove empty channel delete(channels[channel]); } else { // Send channel client list (except themselves) to all clients in the channel try { const members = channels[channel]; for ( const member of members ) { const client = clients[member]; if ( isClientInChannel(client, channel) ) { sendMessage(client.connection, encryptMessage({ a: 'l', p: members.filter(( value ) => { return( value !== member ? true : false ); }) }, client.shared)); } } } catch ( error ) { logEvent('close-list', [ clientId, error ], 'error'); } } } // Remove client from client list if ( clients[clientId] ) { delete(clients[clientId]); } }); }); /* ================== HELPER FUNCTIONS ================== */ // Log event const logEvent = ( source, message, level ) => { if ( level !== 'debug' || config.debug ) { const date = new Date(), dateString = date.getFullYear() + '-' + ( '0' + (date.getMonth() + 1) ).slice(-2) + '-' + ( '0' + date.getDate() ).slice(-2) + ' ' + ( '0' + date.getHours() ).slice(-2) + ':' + ( '0' + date.getMinutes() ).slice(-2) + ':' + ( '0' + date.getSeconds() ).slice(-2); console.log('[' + dateString + ']', ( level ? level.toUpperCase() : 'INFO' ), source + ( message ? ':' : '' ), ( message ? message : '' )); } }; // Generate client id const generateClientId = () => { try { return(crypto.randomBytes(8).toString('hex')); } catch ( error ) { logEvent('generateClientId', error, 'error'); return(null); } }; // Close connection const closeConnection = ( connection ) => { try { connection.close(); } catch ( error ) { logEvent('closeConnection', error, 'error'); } }; // Is client in channel const isClientInChannel = ( client, channel ) => { return( client && client.connection && client.shared && client.channel && client.channel === channel ? true : false ); }; // Send message const sendMessage = ( connection, message ) => { try { if ( connection.readyState && connection.readyState === ws.OPEN ) { connection.send(message); } } catch ( error ) { logEvent('sendMessage', error, 'error'); } }; // Encrypt message const encryptMessage = ( message, key ) => { let encrypted = ''; try { message = Buffer.from(JSON.stringify(message), 'utf8'); if ( ( message.length % 16 ) !== 0 ) { message = Buffer.from([ ...message, ...Buffer.alloc(16 - ( message.length % 16 )) ]); } const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv( 'aes-256-cbc', key, iv ); cipher.setAutoPadding(false); encrypted = iv.toString('base64') + '|' + cipher.update(message, '', 'base64') + cipher.final('base64'); } catch ( error ) { logEvent('encryptMessage', error, 'error'); } return(encrypted); }; // Decrypt message const decryptMessage = ( message, key ) => { let decrypted = {}; try { const parts = message.split('|'); const decipher = crypto.createDecipheriv( 'aes-256-cbc', key, Buffer.from(parts[0], 'base64') ); decipher.setAutoPadding(false); decrypted = JSON.parse(( decipher.update(parts[1], 'base64', 'utf8') + decipher.final('utf8') ).replace(/\0+$/, '')); } catch ( error ) { logEvent('decryptMessage', error, 'error'); } return(decrypted); }; // Get time const getTime = () => { return(new Date().getTime()); }; // Is string const isString = ( value ) => { return( value && Object.prototype.toString.call(value) === '[object String]' ? true : false ); }; // Is array const isArray = ( value ) => { return( value && Object.prototype.toString.call(value) === '[object Array]' ? true : false ); }; // Is object const isObject = ( value ) => { return( value && Object.prototype.toString.call(value) === '[object Object]' ? true : false ); };