DEV Community

Jacky Wong
Jacky Wong

Posted on

So, I developed an instant messaging app with E2EE

M42 (short for Message For Two) is a real-time chat software based on the web that was developed by me. It features end-to-end encryption and does not store chat histories. Today, I want to share with you all the technical details about m42.

A screenshot for m42

Before talking about the design philosophy behind m42, the following assumptions are made:

  • Alice and Bob are two natural persons.
  • Alice and Bob can communicate over the internet, but the environment is not secure.

The design philosophy of m42, given the above assumptions, is as follows:

  • Alice creates a chat room in m42 and generates two links, Link A and Link B. Link A is not displayed to Alice, and Link B is sent to Bob by Alice so that he can click on it and join the chat room. After sending Link B to Bob, Alice clicks on Link A to enter the chat room. From then on, both parties have successfully entered the chat room.
  • When both parties come online, m42 generates a key pair for each of them on their respective client-side, without any interaction with the server. Once generated, the key pair is saved to localStorage, and the public keys are exchanged through WebSocket.
  • Before sending a message, Alice encrypts the message using Bob's public key, a process that takes place locally. Bob receives the encrypted message and decrypts it using his own private key, and vice versa.

Now, let's take a look at the backend:

  • Development language: NodeJs
  • Web server: ExpressJs

The only issue encountered during implementation was how to implement messaging between clients, for which StackOverflow provided a solution:

// rough idea
const express = require('express')
const app = express()
const { WebSocketServer } = require('ws')

const authenticate = function (req, next) {
  next(null, req.headers['sec-websocket-key'])
}

const wss = new WebSocketServer({
  noServer: true
})

let lookup = {}

wss.on('connection', function connection(ws, req, client) {
  lookup[client] = ws
  lookup[client].send(
    JSON.stringify({
      clientID: client
    })
  )
})

const server = app.listen(port, host, () => {
  console.log(`app is on, http://${host}:${port}`)
})

server.on('upgrade', function upgrade(request, socket, head) {
  authenticate(request, function next(err, client) {
    if (err || !client) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
      socket.destroy()
      return
    }

    wss.handleUpgrade(request, socket, head, function done(ws) {
      wss.emit('connection', ws, request, client)
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

This way, every time a WebSocket connection is established, a unique ClientID is obtained, solving the problem of authentication.


Let's talk about the frontend now.

End-to-End Encryption

This step is not difficult, but I took a lot of detours. My initial design was to generate a password on each client, and then exchange them via WebSocket. This approach cannot be called end-to-end encryption because it transmits the password in plaintext. Even if Alice and Bob's messages are encrypted later, as long as a middleman intercepts the password exchange request, everything is useless. This also exposed my ignorance of information transmission encryption.

After some research, I understood why there must be a process of exchanging public keys in end-to-end encryption, note that it is exchanging public keys, not passwords. The role of key pairs is evident here. The public key is used for encryption, while the private key is used for decryption. It can also be understood as the password used for encryption and the password used for decryption being completely different. With the current computing power of home computers, the possibility of brute-force cracking is almost zero.

There are many encryption methods supported by Web APIs, and m42 uses RSA-OAEP-256. You can see the specific usage in this Repo.

Encrypt Long Text

The problem of end-to-end encryption was solved, but in subsequent development, a new problem was encountered: RSA-OAEP-256 can only encrypt data of length 190byte, and an error is reported if the length exceeds. This problem was ultimately solved by splitting the Blob.

function splitAsChunk(size, str, cb) {
  str = encodeURI(str)
  let blob = new Blob([str], {
    type: 'text/plain'
  })
  let splitSize = size || 150
  let chunkArr = []
  let loopTimes = Math.ceil(blob.size / splitSize)
  for (let i = 0; i < loopTimes; i++) {
    let el = blob.slice(i * splitSize, (i + 1) * splitSize)
    el.text()
      .then((res) => {
        chunkArr[i] = res
        if (i + 1 === loopTimes) {
          cb && cb(null, chunkArr)
        }
      })
      .catch((err) => {
        cb && cb(err)
      })
  }
}

function reformChunkAsString(chunks, cb) {
  let blob = new Blob(chunks, {
    type: 'text/plain'
  })
  blob
    .text()
    .then((res) => {
      cb && cb(null, decodeURI(res))
    })
    .catch((err) => {
      cb && cb(err)
    })
}
Enter fullscreen mode Exit fullscreen mode

Encrypt Files

The most basic function of sending encrypted text was finished, but if we stopped here, the chat app was too plain. So I added file sharing functionality as well. Yes, file sharing is also end-to-end encrypted! However, there were some obstacles here. At first, I wanted to process files for encryption by dealing with characters, but later I found that the performance was really poor! For example, for a 1Mb picture, we need to loop over more than 5000 times and each slice needs to be encrypted with a public key! Such operations take 4 - 5 seconds on the browser of a computer, not to mention a mobile phone! Finally, I gave the solution:

  • Encrypt the file with Rabbit and 128-length password.
  • Encrypt Rabbit Password using RSA-OAEP-256
  • Cut the encrypted password into chunks and add the encrypted password together to send though WebSocket.

The performance test showed that a 1Mb picture almost finished encrypting instantly, but due to the efficiency issue of WebSocket file transmission, the actual file size that can be transmitted in m42 was limited to 30Mb. It is still recommended that users use professional file transmission services for files larger than this size. However, this solution is not perfect:

  • Can't detect file upload progress
  • Sending a large amount of data through WebSocket will block the transmission of subsequent text messages

These two issues can be solved by transmitting the encrypted file through a server, but this also goes against the original design intent of m42 — not to store any user data on server.


Alright, I think that's all, what do you think?

Top comments (0)