Secure chat: implementing end-to-end encryption in Flutter chat apps

In today’s world, chat applications have become an indispensable part of our daily lives, serving as vital tools for both personal and professional communication. Nevertheless, the rise in data breaches and cyber-attacks has raised concerns about the privacy and security of our conversations. To safeguard our sensitive information from prying eyes, the implementation of end-to-end encryption (E2EE) has become one of the most important aspects when developing chat applications.

End-to-end encryption (E2EE) is a potent security measure that guarantees message privacy, ensuring that only the sender and recipient can decrypt the content. In this article, we will explore the intricacies of implementation of end-to-end encryption into chat applications.

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

 

End-to-end encryption is a robust security measure that ensures messages are encrypted at the sender’s device and decrypted at the receiver’s device. This stringent encryption process renders the message inaccessible to anyone else, including the service provider. The encryption and decryption keys are exclusively stored with the sender and receiver, thereby making it highly challenging for any third-party to intercept or decipher the message.

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

 

End-to-end encryption (E2EE) plays a critical role in maintaining privacy and security in chat applications for several reasons:

  1. Data Privacy and Security: Chat applications often handle sensitive and personal data, such as financial details, health information, or confidential conversations. With end-to-end encryption, the content of messages remains encrypted throughout its entire journey—from the sender’s device to the receiver’s device. This ensures that only the intended recipient can decrypt and access the message. No one else, including the service provider, hackers, or even government entities, can access the content of the messages.
  2. Security Against Eavesdropping: With E2EE, even if the data transmission is intercepted during transit, the data cannot be decrypted or understood by anyone other than the recipient. This includes the service provider, Internet Service Provider (ISP), hackers, or any third parties. The encrypted data is unreadable and meaningless to unauthorized individuals.
  3. Protection Against Data Breaches: Even if a service provider’s servers are hacked or compromised, E2EE keeps your messages safe as they are not stored in a readable format. The messages are only decrypted on the recipient’s device.
  4. User Trust: In an age where data privacy has become a significant concern, providing end-to-end encryption can boost user trust. Users can have peace of mind knowing their private conversations are secure.

In summary, end-to-end encryption is vital in chat applications to protect user privacy, ensure data security, and create a trustworthy environment where users can communicate freely without fear of interception.

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 Flutter we will use cryptography:

cryptography: ^2.5.0 // the latest version at this time

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.

The first step, a user – the initiator of a secret chat – generates key data:

final keyAlgorithm = X25519();

final aliceKeyPair = await keyAlgorithm.newKeyPair();
final alicePublicKey = await aliceKeyPair.extractPublicKey();

In the second step, this user sends key data to another user.

var publicKeyString = base64Encode(alicePublicKey.bytes);
var secretDialogId = 'some-random-generated-chat-id';
var userId = 12345; // the `id` of the opponent user

var systemMessage = CubeMessage()
  ..recipientId = userId
  ..properties = {
    'publicKey': publicKeyString,
    'secretDialogId': secretDialogId
};

CubeChatConnection.instance.systemMessagesManager?.sendSystemMessage(systemMessage);

In the third step, the other user receives key data, then generates his own key data, and then sends his own key data back to the initiator, so the initiator can also calculate same shared secret:

CubeChatConnection.instance.systemMessagesManager?.systemMessagesStream.listen( (systemMessage){
 var senderId = systemMessage.senderId;
 var secretDialogId = systemMessage.properties['secretDialogId'];
 var publicKeyString = systemMessage.properties['publicKey'];

 var alicePublicKeyRestored = SimplePublicKey(
   base64Decode(publicKeyString),
   type: KeyPairType.x25519);

 final keyAlgorithm = X25519();
 final bobKeyPair = await keyAlgorithm.newKeyPair();

 final bobSecretKey = await keyAlgorithm.sharedSecretKey(
   keyPair: bobKeyPair,
   remotePublicKey: alicePublicKeyRestored,
 );
 
 final bobPublicKey = await bobKeyPair.extractPublicKey();

 var bobPublicKeyString = base64Encode(bobPublicKey.bytes);

 var responseSystemMessage = CubeMessage()
   ..recipientId = senderId
   ..properties = {
     'publicKey': bobPublicKeyString,
     'secretDialogId': secretDialogId!
   };

 CubeChatConnection.instance.systemMessagesManager?.sendSystemMessage(responseSystemMessage);
});

In the fourth step, the initiator receives other user’s key data:

CubeChatConnection.instance.systemMessagesManager?.systemMessagesStream.listen( (systemMessage){
 var senderId = systemMessage.senderId;
 var secretDialogId = systemMessage.properties['secretDialogId'];
 var publicKeyString = systemMessage.properties['publicKey'];
 var bobPublicKeyRestored = SimplePublicKey(base64Decode(publicKeyString),
   type: KeyPairType.x25519);

 final aliceSecretKey = await keyAlgorithm.sharedSecretKey(
   keyPair: aliceKeyPair,
   remotePublicKey: bobPublicKeyRestored,
 );
});

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:

Future<Map<String, String>> encrypt(SecretKey secretKey, String text) async {
 final algorithm = AesCtr.with256bits(
   macAlgorithm: Hmac.sha256(),
 );

 final secretBox = await algorithm.encrypt(
   utf8.encode(text),
   secretKey: secretKey,
 );

 return {
   'nonce': base64Encode(secretBox.nonce),
   'content': base64Encode(secretBox.cipherText),
   'mac': base64Encode(secretBox.mac.bytes)
 };
}

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

Future<String> decrypt(SecretKey secretKey, Map<String, String> secretBox) async {
 final algorithm = AesCtr.with256bits(
   macAlgorithm: Hmac.sha256(),
 );

 var incomingSecretBox = SecretBox(
   base64Decode(secretBox['content']!),
   nonce: base64Decode(secretBox['nonce']!),
   mac: Mac(
     base64Decode(secretBox['mac']!),
   ),
 );

 return algorithm.decrypt(incomingSecretBox, secretKey: secretKey,).then((raw) {
   return utf8.decode(raw);
 });
}

Encrypt the Message

 

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

var message = 'Hello world!';
var secretBox = await encrypt(aliceSecretKey, message);

Transmit the Message

 

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

var cubeDialog; // some instance of the `CubeDialog`

var secureMessage = CubeMessage()
 ..body = 'Encrypted message'
 ..properties = await encrypt(aliceSecretKey, message);

cubeDialog.sendMessage(secureMessage);

Decrypt the Message

 

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

CubeChatConnection.instance.chatMessagesManager?.chatMessagesStream.listen((cubeMessage) {
 if (cubeMessage.dialogId == secureDialogId) {
   var decryptedBody = await decrypt(bobSecretKey, cubeMessage.properties);
 }
});

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. Typically, messages are stored in a database on the device, which poses a potential risk of unauthorized entities accessing the plaintext content.

Therefore, it’s worthwhile to consider enhancing a chat application’s security measures 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. Hence, each time a user launches the application, a specific pin is requested to decrypt the database and access the content of the messages.

We will explore this topic in more depth in further blog posts.

Conclusion

 

End-to-end encryption is a critical security measure that guarantees only the message sender and recipient can access the content of the message, providing a significant level of privacy and security against unauthorized access. The implementation process in chat applications involves creating encryption and decryption keys, ciphering the message, sending it securely, deciphering the message upon receipt, and subsequently erasing the encryption and decryption keys. For chat applications that prioritize privacy and security, implementing end-to-end encryption is paramount, as it guarantees that users’ conversations remain confidential and well-protected.

Links

 

ConnectyCube Flutter SDK