Skip to content

Instantly share code, notes, and snippets.

@ikouchiha47
Last active May 14, 2021 11:12
Show Gist options
  • Save ikouchiha47/d52bda0306214d07552eb3aa04f6e019 to your computer and use it in GitHub Desktop.
Save ikouchiha47/d52bda0306214d07552eb3aa04f6e019 to your computer and use it in GitHub Desktop.
Implementing a peer to peer chat prototype in nodejs.

prologue

Why another tutorial? Because there are other tutorials which write a server - client or maybe a client client where they have only two clients, maybe by hardcoding ips.

<rant>
  What annoys me more is, that there are 100's of same tutroials on the internet, 
  that just works for a given scenario, but guess what, I don't fucking care what works
  for the sake of writing the tutorial.
  I also don't care about why you didn't write how you solved the problem.
  And when someone asks for help, you just turn them down, saying your design is bullshit.

  You have no right to call my design bullshit, when you don't know how to
  solve a problem or can't help others solve it.
  
</rant>

All of you, frustrate me.

Tutorials are like porn, the same unrealistic shit over and over.

except this.

Peer to Peer chat over LAN and with some encryption.

Codez

Tools used:

  1. Nodejs
  2. Google
  • Why nodejs, it doesn't matter.
  • Why google, because I had to read about mainline DHT, public - private key encryption

How it works

In the server-client chat porns, there is a tcp server called server.js, and then you have another file called client.js, But be fucking realistic, how are you even going to get the ip's and port's.

This is a peer-to-peer chat, so there is no separate server and client apps. A single app, where the client will accepts connections and responds to the clients on separate sockets.

Problem : how do you get the list of connected users. TCP is not a right choice here. What we need is UDP, because this is LAN, one can broadcast or multicast. I opt for broadcast.

So how to do it.

You create two dgram sockets, one will be listening on the broadcast ip at a PORT and then another dgram socket which writes on the brodacast channel.

So when someone connects, he/she broadcasts his/her ip and nick name. The other guys listening on the broadcast port responds to this, by sending their list of connected clients list. Which now you take and store it in a hash, (storing in a hash in JS {[nick]: { ip: [ip] }} will remove the duplicity).

Same when someone leaves/disconnects.

  // write to broadcast ip
  let broadcastClient = dgram.createSocket('udp4'),
      message = new Buffer(JSON.stringify({type: 'nick', ip: myIp, ...other_stuff}))

    broadcastClient.bind(clientPort, () => {
      //console.log('broadcast client running on PORT ', 5124)

      broadcastClient.setBroadcast(true)
      broadcastClient.send(message, 0, message.length, serverPort, broadcastIp, () => {
        broadcastclient.close(() => { console.log('client shutdown') })
      })
    })
    
    let bServer = dgram.createSocket('udp4');

    bServer.on('message', (msg, rinfo) => parseBroadcastMessage(msg, rinfo, nick))

    bServer.on('close', () => {
      broadcastMessageToClients({ nick: nick, type: 'leave' }) // broadcasts when someone leaves
    })

    bServer.bind(BRODCASTPORT, () => {
      console.log('broadcast server listening on PORT ', BROADCASTPORT)
    })
  })

Having done that ^, you also need to create a tcp server, that listens, on a PORT (like 5000 or so, whichever you decide to).

    net.createServer(sock => {
      sock.on('data', (data) => {
        address = sock.remoteAddress == '::ffff:127.0.0.1' ? Utils.ips()[0] : sock.remoteAddress
        
        // if its 127.0.0.1, replace with your ip address. 
        // which means you are sending messages to yourself, is just a safety check.
        // generally, sock.remoteAddress would point to the one who is sending you the message.
        
        parseUserMessage(buff.slice(0, len), { address: address })
        buff = Buffer.from(buff.slice(len, buff.length))
        len = 0
      })
    }).listen(PORT, () => { console.log('connected'); })

Now that you have the list of clients, you can either do an electron app, or a command line chat. Anyway, lets say cli.

You get the list of clients by typing /users. which gives you something like <nick> - <ip>

And now connect like /connect <nick>. By doing this, you could of course create a socket, which will be used to write to the target users tcp server socket.

How?

When you say /connect <nick> , it finds the ip from a hash table, and then you send a message to the corresponding ip on the PORT

  let client = net.connect({ port: PORT, host: address }, () => {
    client.write(Buffer.from(message, 'utf8'))
  })

The message may contain your ip address/nick, which upon receiving, could do things, as per your wish.

But we need to encrypt the messages, we will be using a public private key encryption. How does that work? Well, you generate a DiffeHellman keys. like this let alice = new DeffMan().

As the concept goes, both Alice and Bob should agree on a generator and a prime number to bob, so that key can generate his keys. Having done that, both of them need to share their public keys with each other.

So at fisrt, let alice generate the keys and send them to bob: JSON.stringify({ type: 'keyxchange_alice', from: from, to: to, prime: alice.sharedPrime, generator: alice.generator, key: alice.getPublicKey() })

And then bob will need to generate and send his public key to alice

  const bob = new DeffMan(Buffer.from(msg.prime), Buffer.from(msg.generator))
  const bob_key = bob.getPublicKey()
  JSON.stringify({ type: 'keyxchange_bob', key: bob_key })

Also you will need to store these keys corresponding users, which could be done by storing it in a javascript object, like alice can store: { bob: bobMessage.key }.

Now given that they have each other's public keys, alice can bob can generate a shared secret, shared secret, for bob, when generalized is alicePublicKey ^ bobPrivateKey. (read more on DiffeHelman key exchange from security.stackexchange.com)

This shared secret then will use as a password to encrypt the messages using aes-256-cbc that will be send over tcp.

The above thing can be modified more, by regenerating the secret's everytime, which will involve one more roundrtrip for each message Or One could use the Double-Rachet scheme.

So most of the setup is complete, except that tcp is a stream based protocol, unlike udp. and udp is limited to 65535 bytes. And also tcp is reliable for data-transfer.

So you inorder to parse the complete messages, one would need a message boundary or one could send two messages, Since we are using a Buffer, we can use the first 4bytes (1byte = 1 octet = 8bits) to send the length of the message. And then send the message. On the other end, one can continue reading uptil that length of the buffer, appending the previous chunks. And for any remaning/overflowing data where length of incomming buffer > expected length of message. Re-init a new buffer. (which possibly is going to have its first four bytes as the length of message).

Now since we are dealing with bits, the endian-ness comes into play. Generally the network endian-ness is big-endian.

function sendClientMessage(message, address, cb) {
  let buff = Buffer.alloc(4)
  let hex = message.length.toString(16)

  let client = net.connect({ port: PORT, host: address }, () => {
   if(hex.length % 2 !== 0) hex = '0' + hex // why this, because the writeUIntBE excepts the hex value to be even numbered. 
   // the `toString`, can return you something like this `fe8`, when in reality it should be `0fe8` (for hex in this example)

    buff.writeUIntBE(`0x${hex}`, 0, 4)

    client.write(buff)
    client.write(Buffer.from(message, 'utf8'))
  })
}

and then on the reciving end, you would read 4 octes and slice the buffer.

  sock.on('data', (data) => {
    buff = Buffer.concat([buff, data])

    if(!len) {
      len = buff.readUIntBE(0, 4)
      buff = buff.slice(4)
    }

    if(buff.length >= len) {
      parseUserMessage(buff.slice(0, len), { address: address })
      buff = Buffer.from(buff.slice(len, buff.length))
      len = 0
    }
  })

So to summarize:

  • When user joins, the user send a broadcast message using udp. The user also opens a tcp socket to listen to incomming messages
  • When a broadcast message is recieved, the appropiate action is take
  • When an user tries to send a message to another user, the system initiaites a key exchange
  • A shared secret is generated and is stored in a hash corresponding to the other user. This shared secret is used for encrypting messages
  • When the user sends a message. The first 4/5 bytes is used to spectify the length of the message, and then the rest is the message itself
  • On reciving a message, concat the incomming buffer to the previous buffer (can be empty). if the length is not set, the user reads the first 4/5 bytes and sets the length which is the expected length of the message if the length is set, check if the resultant length is >= the expected length. if so, strip the extra bytes. and re-assign the original buffer with the present one and set the length to 0, and store the previous buffer (as message).

Thanks to: @awalGarg @security.SE @youtube @some_person_in_irc

improvements to the design/architecture are always welcome

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment