Features Developers Pricing
Company
Request demo Log in Sign up
Holiday sale: Get 25% Off all plans!
Promocode: CC2025

Reading time 0 mins

Linked-in

Implementing End-to-End Encryption in Flutter Chat Apps: A Guide to Secure Messaging

In today’s digital age, where communication plays a vital role in both personal and professional spheres, ensuring the security and privacy of our conversations has become increasingly important. With the ever-growing threat of data breaches and privacy infringements, users are increasingly seeking ways to safeguard their personal and sensitive information from prying eyes. Accordingly,  there is a growing demand for robust encryption methods to safeguard sensitive conversations. 

One of the most effective methods to achieve this level of security is through the implementation of End-to-End Encryption (E2EE) in messaging applications. E2EE stands as a formidable solution to this challenge, offering a robust mechanism to safeguard user communications from unauthorized access. 

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

End-to-end encryption (E2EE) is a method of secure communication that ensures that data being sent between two parties is encrypted and only the sender and the intended recipient of a message can access its contents. This means that the data is encrypted on the sender’s device and decrypted only on the recipient’s device, with no intermediate party (including service providers, internet service providers, or hackers) being able to read the encrypted data. 

How E2EE Works:

  • Encryption on Sender’s Device:

Before the data (such as a message, file) leaves the sender’s device, it is encrypted using a cryptographic key. This key is unique and not shared with anyone else.

  • Transmission:

The encrypted data is transmitted over the internet to the recipient’s device. Even if intercepted during transmission, the data remains unreadable to anyone who does not have the decryption key.

  • Decryption on Recipient’s Device:

Upon arrival, the data is decrypted on the recipient’s device using a corresponding decryption key that only the recipient possesses.

Key Features of E2EE:

Privacy and Security:

  • Only the communicating users can read the messages, providing a high level of privacy. Even if data is intercepted during transmission, it remains unintelligible to unauthorized parties.
  • Service providers that facilitate the communication cannot read the encrypted data, ensuring that user privacy is maintained.

Integrity:

Ensures that the message has not been altered during transmission.

Authentication:

Verifies the identities of the communicating parties, ensuring that the message is being sent and received by the intended parties.

Trust:

Builds user trust in the communication platform as it guarantees secure messaging.

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

Let’s dive into the process of implementing end-to-end encryption (E2EE) in a chat application. Using our chat sample as an example, we’ll demonstrate just how straightforward it can be to integrate E2E encryption into your existing app with the ConnectyCube SDK. Here’s how you can get started:

First, clone the source code to your local machine using the simple Git command:

git clone https://github.com/ConnectyCube/connectycube-flutter-samples.git

Next, navigate to the chat_sample directory with the command:

cd connectycube-flutter-samples/chat_sample

With this, you’ve completed the first step!

Now, let’s move on to implementing E2E encryption. To do this, we need to generate encryption keys and store them locally in the secured storage. We’ll utilize third-party libraries to accomplish this. For key generation and message encryption/decryption, we will use the following libraries:

cryptography: ^2.7.0

cryptography_flutter: ^2.3.2

To add the first library to your app, use the command:

flutter pub add cryptography

And to add the second library, use:

flutter pub add cryptography_flutter

With these dependencies in place, you’re well on your way to securing your chat application with robust end-to-end encryption!

For storing keys in the secured storage we will use the following lib:

flutter_secure_storage: ^9.2.2

and for adding it into your app use the following command:

flutter pub add flutter_secure_storage

Let’s get down to the feature implementation!

In our app, we’ll have a separate key pair for each opponent dialog, ensuring maximum security. The first step is to generate a public key and send it to the opponent.

We’ll create the key pair, send the public key to our opponent, and save this pair to generate a secret key once we receive the opponent’s public key. Here’s the code to accomplish this:

Future<void> initKeyExchangeForUserDialog(String dialogId, int userId) async {
    final keyPair = await keyAlgorithm.newKeyPair();
    final publicKey = await keyPair.extractPublicKey();

    var publicKeyString = base64Encode(publicKey.bytes);

    saveKeyPairForUserDialog(dialogId, userId, keyPair);

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

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

As you can see we use the SystemMessagesManager to send the public key to the opponent.

On the opponent’s side, we also use the SystemMessagesManager for listening to the key exchange requests and sending responses. To set this up, we need to add a listener for system messages. Add the next code to listen system messages:

systemMessagesSubscription = CubeChatConnection.instance.systemMessagesManager?.systemMessagesStream.listen(onSystemMessageReceived);

Future<void> onSystemMessageReceived(CubeMessage systemMessage) async {
    var senderId = systemMessage.senderId;
    var secretDialogId = systemMessage.properties['secretDialogId'];
    var publicKeyString = systemMessage.properties['publicKey'];

    if ((secretDialogId?.isEmpty ?? true) ||
      (publicKeyString?.isEmpty ?? true)) {
        return;
    }

    var exchangeType = systemMessage.properties['exchangeType'];
    var publicKey = SimplePublicKey(base64Decode(publicKeyString!), type: KeyPairType.x25519);

    if (exchangeType == 'request') {
        final keyPair = await keyAlgorithm.newKeyPair();

        final secretKey = await keyAlgorithm.sharedSecretKey(
            keyPair: keyPair,
            remotePublicKey: publicKey,
        );

        saveSecretKeyForUserDialog(secretKey, secretDialogId!, senderId!);
        // save the same key for the current user to allow decryption of own messages if needed
        // in this sample used for decryption of own messages received through API request
        // it can be ignored in a real app if messages aren't stored on the backend
        saveSecretKeyForUserDialog(secretKey, secretDialogId, CubeChatConnection.instance.currentUser!.id!);

        final responsePublicKey = await keyPair.extractPublicKey();

        var responseSystemMessage = CubeMessage()
            ..recipientId = senderId
            ..properties = {
                'exchangeType': 'response',
                'publicKey': base64Encode(responsePublicKey.bytes),
                'secretDialogId': secretDialogId
            };

        CubeChatConnection.instance.systemMessagesManager?.sendSystemMessage(responseSystemMessage);
    } else if (exchangeType == 'response') {
        var keyPairForUserDialog = await getKeyPairForUserDialog(secretDialogId!, senderId!);

        if (keyPairForUserDialog != null) {
            final secretKey = await keyAlgorithm.sharedSecretKey(
                keyPair: keyPairForUserDialog,
                remotePublicKey: publicKey,
            );

            saveSecretKeyForUserDialog(secretKey, secretDialogId, senderId);
            // save the same key for the current user to allow decryption of own messages if needed
            // in this sample used for decryption of own messages received through API request
            // it can be ignored in a real app if messages aren't stored on the backend
            saveSecretKeyForUserDialog(secretKey, secretDialogId, CubeChatConnection.instance.currentUser!.id!);
        }
    }
}

In the code provided above, we first check if the system message relates to the key exchange process. If it is a key request, we generate our own key pair and a secret key for the opponent, which we then save in secure storage. If it’s a response to our request, we get the public key and use our saved key pair to generate the secret key, which we also store securely.

At this point, we have the secret keys necessary for encrypting and decrypting messages. 

To streamline this process, we’ve prepared the E2EEncryptionManager, which consolidates all the code related to managing end-to-end encryption:

https://github.com/ConnectyCube/connectycube-flutter-samples/blob/feature/e2e_encryption/chat_sample/lib/src/managers/e2e_encryption_manager.dart

This manager should be initialized after a successful login to the chat. Use the following code for this:

CubeChatConnection.instance.login(user).then((cubeUser){
   E2EEncryptionManager.instance.init();
   … // other code
});

And it should be destroyed after logging out, as demonstrated in our sample:

CubeChatConnection.instance.destroy();
E2EEncryptionManager.instance.destroy();

Next, we create the dialog with the flag isEncrypted = true 

It allows us to identify if the E2E encryption is applied for this chat dialog.

CubeDialog newDialog = CubeDialog(
    CubeDialogType.PRIVATE,
    occupantsIds: [opponentId],
)..isEncrypted = true;

createDialog(newDialog).then((createdDialog) {
    E2EEncryptionManager.instance.initKeyExchangeForUserDialog(createdDialog.dialogId!, users.first);
    … // other required code
}).catchError((error) {
    _processCreateDialogError(error);
});

Now that we have the mechanism for key generation and exchange, a manager that handles message encryption and decryption, and a chat marked as encrypted, we can proceed to send and receive encrypted messages. In our sample, we simply modified a few methods for sending and receiving these messages. Here’s the code for sending an encrypted message:

BEFORE:

void onSendMessage(CubeMessage message) async {
    log("onSendMessage message= $message");
    textEditingController.clear();
    await widget.cubeDialog.sendMessage(message);
    message.senderId = widget.cubeUser.id;
    addMessageToListView(message);
    listScrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
    if (widget.cubeDialog.type == CubeDialogType.PRIVATE) {
        ChatManager.instance.sentMessagesController.add(message..dialogId = widget.cubeDialog.dialogId);
    }
}

AFTER:

void onSendMessage(CubeMessage message) async {
    log("onSendMessage message= $message");
    textEditingController.clear();

    if (widget.cubeDialog.isEncrypted ?? false) {
        await widget.cubeDialog.sendMessage(await E2EEncryptionManager.instance.encryptMessage(
            message,
            widget.cubeDialog.dialogId!,
            widget.cubeDialog.occupantsIds!.where((userId) => userId != widget.cubeUser.id).first)
        );
    } else {
        await widget.cubeDialog.sendMessage(message);
    }

    message.senderId = widget.cubeUser.id;
    addMessageToListView(message);
    listScrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
    if (widget.cubeDialog.type == CubeDialogType.PRIVATE) {
        ChatManager.instance.sentMessagesController.add(message..dialogId = widget.cubeDialog.dialogId);
    }
}

The code for receiving:

BEFORE:

void onReceiveMessage(CubeMessage message) {
    log("onReceiveMessage message= $message");
    if (message.dialogId != widget.cubeDialog.dialogId) return;

    addMessageToListView(message);
}

AFTER:

Future<void> onReceiveMessage(CubeMessage message) async {
    log("onReceiveMessage message= $message");
    if (message.dialogId != widget.cubeDialog.dialogId) return;

    if (widget.cubeDialog.isEncrypted ?? false) {
        message = await E2EEncryptionManager.instance.decryptMessage(message);
    }

    addMessageToListView(message);
}

We’re almost there! Just one more configuration step remains, but it can be optional depending on your needs. If you choose not to store encrypted messages on the backend, you can simplify your setup by using the saveToHistory = false field in CubeMessage to disable backend storage of messages.

However, if you decide to keep encrypted messages on the backend, you’ll need to ensure they are decrypted once fetched from the server. To handle this, simply add the following code snippet to your message processing logic:

BEFORE:

return result!.items;

AFTER:

if (widget.cubeDialog.isEncrypted ?? false) {
    return await E2EEncryptionManager.instance.decryptMessages(result!.items);
}

return result!.items;

This final step will ensure your encrypted messages are securely managed according to your storage preferences.

Conclusion

In conclusion, implementing end-to-end encryption (E2EE) in your Flutter chat app is a crucial step towards ensuring the privacy and security of your users’ communications. With this guide, we’ve walked you through the step-by-step process of integrating end-to-end encryption using the ConnectyCube SDK. By following these steps, you can ensure that all messages exchanged through your app remain confidential and protected from unauthorized access.

Ready to enhance the security of your chat application? Register for a free account today to start implementing end-to-end encryption and provide your users with the peace of mind they deserve. If you haven’t already, explore the powerful ConnectyCube Flutter SDK and take the next step in securing your app.

Get started now and elevate your app’s security standards!

Get started for FREE

Unlock the benefits of tomorrow by signing up today.

Don't wait, take the first step towards a brighter future with us!