Secure chat: implementing end-to-end encryption in Web and React Native chat apps

Chat applications have become an integral part of our lives, whether it’s for personal or professional communication. However, with the increasing number of data breaches and cyber-attacks, it has become necessary to protect our conversations from prying eyes. End-to-end encryption (E2EE) is a security feature that ensures that only the sender and receiver can read the messages, keeping them private and secure from outside threats. In this blog post, we will discuss the implementation of end-to-end encryption in chat applications.

What is End-to-End Encryption (E2EE)?

 

End-to-end encryption is a security feature that encrypts the message at the sender’s device and decrypts it at the receiver’s device, making it impossible for anyone else, including the service provider, to access the message. The encryption and decryption keys are kept only with the sender and receiver, making it difficult for anyone to intercept or read the message.

Why is End-to-End Encryption Important in Chat Applications?

 

Chat applications, such as Signal, have implemented end-to-end encryption, making them popular among users who prioritize privacy and security. End-to-end encryption ensures that only the sender and receiver can read the messages, keeping them private and secure from prying eyes, including hackers, governments, and service providers. In addition, end-to-end encryption provides the following benefits:

  1. Authenticity: End-to-end encryption ensures that the message is not tampered with during transit, as the encryption keys are only with the sender and receiver.
  2. Confidentiality: End-to-end encryption ensures that only the sender and receiver can read the message, keeping it confidential from outsiders.
  3. Integrity: End-to-end encryption ensures that the message is not altered during transit, ensuring that the message’s integrity is maintained.

How to Implement End-to-End Encryption in Chat Applications?

 

As for the encryption flow, we will be using DH + AES:

  • Diffie-Hellman Key Exchange (DH): DH is a key exchange protocol that enables two parties to establish a shared secret over an insecure channel. DH is not used for encryption or decryption but is used to establish a shared secret key that can be used for symmetric encryption.
  • AES encryption, or advanced encryption standard, is a type of cipher that protects the transfer of data online. AES is a symmetric type of encryption, as it uses the same key to both encrypt and decrypt data.

 

To implement end-to-end encryption in chat applications, the following steps need to be taken:

Choose and connect proper libs for working with crypto primitives

 

For web we will use crypto-browserify:

import crypto from "crypto-browserify";

For React Native we will use react-native-quick-crypto:

import RNQuickCrypto as crypto from 'react-native-quick-crypto'; 

Implement key exchange protocol and get shared secret key:

 

Both parties (users) should implement the following DH key exchange flow to obtain a shared secret key.

First step, a user – initiator of a secret chat – generates key data:

const curveName = 'prime192v1'; 
const keyObject = crypto.createECDH(curveName);

Second step, this user sends key data to other user:

const publicKeyBase64 = keyObject.generateKeys().toString('base64');
const secretDialogId = 'some-random-generated-chat-id';

ConnectyCube.chat.sendSystemMessage(userId, {
  extension: {
    publicKey: publicKeyBase64, 
    curveName, 
    secretDialogId
  }
});

Third step, other user receives key data, then generates own key data, calculate secret key and then send own key data back to initiator, so the initiator can also calculated same shared secret:

let keyObject;

ConnectyCube.chat.onSystemMessageListener = (msg) => {
  const userId = msg.userId;
  const {secretDialogId, publicKey, curveName} = msg.extension;

  // generate key data
  keyObject = crypto.createECDH(curveName); 

  // calculate shared secret key
  const receivedPublicKey = Buffer.from(publicKey, 'base64');
  const secretKey = keyObject.computeSecret(receivedPublicKey);

  // send key data back to initiator
  //
  if (curveName) { // check for 'curveName' since only initiator sends it
    const publicKeyBase64 = keyObject.generateKeys().toString('base64');
    ConnectyCube.chat.sendSystemMessage(userId, {
      extension: {
        publicKey: publicKeyBase64, 
        secretDialogId
      }
    });
  }
};

Fourth step, initiator receives other user’s key data, and then calculate secret key:

ConnectyCube.chat.onSystemMessageListener = (msg) => {
  const userId = msg.userId;
  const {secretDialogId, publicKey} = msg.extension;

  // calculate shared secret key
  const receivedPublicKey = Buffer.from(publicKey, 'base64');
  const secretKey = keyObject.computeSecret(receivedPublicKey);
}

And now, from this point, both users can use secretKey to encrypt/decrypt messages.

Implement Encryption / Decryption methods

 

Using original text and a secret key, define a method which will encrypt the data:

const encrypt = async (text, secretKey) => {
 return new Promise((resolve) => {
   const algorithm = "aes-256-ctr"; // Name algorithm
   const iv = crypto.randomBytes(16); // initial vector

   const cipher = crypto.createCipheriv(algorithm, secretKey, iv);

   const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);

   resolve({
     iv: iv.toString("hex"),
     content: encrypted.toString("base64")
   });
 });
};

Using encrypted text and a secret key, define a method which will decrypt the data:

const decrypt = async (hash, secretKey) => {
 return new Promise((resolve) => {
   const algorithm = "aes-256-ctr"; // Name algorithm

   const decipher = crypto.createDecipheriv(
     algorithm,
     secretKey,
     Buffer.from(hash.iv, "hex")
   );

   const decrypted = Buffer.concat([
     decipher.update(Buffer.from(hash.content, "base64")),
     decipher.final()
   ]);

   resolve(decrypted.toString());
 });
};

Encrypt the Message

 

The sender encrypts the message using the encryption key, ensuring that only the receiver can read the message:

const message = "Hello world!";

const encryptedMessage = await encrypt(
   message,
   secretKey.toString("base64")
);

Transmit the Message

 

The encrypted message is then transmitted to the receiver through the chat application or other means of communication.

const opponentId = 78;

const message = {
  type: 'chat',  
  body: "encrypted message",
  extension: {
    encrypted_message_iv: encryptedMessage.iv,
    encrypted_message_content: encryptedMessage.content,
  }
};

message.id = ConnectyCube.chat.send(opponentId, message);

Decrypt the Message

 

The receiver uses their decryption key to decrypt the message, ensuring that only they can read the message.

ConnectyCube.chat.onMessageListener = onMessage;

function onMessage(userId, message) {
   const encryptedData = {
     content: message.extension.encrypted_message_content,
     iv: message.extension.encrypted_message_iv
   }

   const decryptedMessage = await decrypt(
      encryptedData,
      secretKey.toString("base64")
   );
}

Delete the Encryption and Decryption Keys

 

Once the chat session is done, the encryption and decryption keys are deleted from the sender and receiver’s devices, ensuring that the messages are not compromised.

Next steps

 

Despite the fact that now messages are encrypted while in transit,  they stay in plain text while at rest. Normally, the messages are stored in DataBase on the device, and while they are staying in plain text while in DB, there is a chance it can be read by some unauthorized entity.

Hence it makes sense to further advance a chat app security by implementing e.g. a crypto container. A crypto container is a way of encrypting chat messages while at rest. Usually, it’s implemented by encrypting the whole DB or the data in DB with some user pin. So each time a user runs an application, we request a special pin which is used to decrypt the DB and access the messages content.

We will review this topic in detail in further blog posts.

Conclusion

 

End-to-end encryption is an essential security feature that ensures that only the sender and receiver can read the message, keeping it private and secure from outsiders. Implementing end-to-end encryption in chat applications requires generating encryption and decryption keys, encrypting the message, transmitting the message, decrypting the message, and deleting the encryption and decryption keys. Chat applications that prioritize privacy and security should implement end-to-end encryption to ensure that users’ conversations are kept private and secure.

Links

 

ConnectyCube JavaScript SDK

ConnectyCube React Native SDK

ConnectyCube Cordova SDK