End-to-End Encrypted Chat in Flutter

When you communicate over a chat application with another person or group, you may exchange sensitive information, like personally identifiable information, financial details, or passwords. To ensure that your data stays secure, a chat application must use end-to-end encryption.

In this tutorial, you’ll learn the basics of end-to-end encryption and how to use it in your Stream Flutter chat application.

We’ll cover the following in detail:

  • What’s End-to-End Encryption?
  • What is the Web Cryptography API?
  • Generating a Cryptographic Key Pair
  • Generating a Crypto Key (Derivebits)
  • Encrypting Messages
  • Decrypting Messages
  • Implementing a Chat Feature
  • Preparing the App for End-to-End Encryption
  • Sending Encrypted Messages
  • Showing Decrypted Messages

Note: Before you start, keep in mind that this tutorial is a basic example intended for educational purposes only.

If you want to implement end-to-end encryption in your production app, please consult a security professional first. There’s a lot more to consider from a security perspective that isn’t covered here.

What’s End-to-End Encryption?

End-to-end encryption (E2EE) is the process of securing a message from third parties so that only the sender and receiver can access the message. E2EE provides security by storing the message in an encrypted form on the server or database running the application.

You can only access the message by decrypting and signing it using a known public key (distributed freely) and a corresponding private key (only known by the owner).

Each user in the application has their own public-private key pair. Public keys are distributed publicly and encrypt the sender’s messages. The receiver can only decrypt the sender’s message with the matching private key, which is used to decrypt messages and to verify or sign them.

Check out the diagram below for an example:

Let’s walk through each step to see what’s happening:

  • Bob (sender) types “Hello” and sends the message to Alice.
  • After Bob selects send, the database receives and encrypts the message using a combination of Alice’s (receiver) public key and Bob’s private key.
  • On Bob’s side, the server converts the message to a ciphertext ($#cs4$vxxv!~) and stores it in the database.
  • Alice receives the encrypted message and decrypts it using her private key and Bob’s public key.
  • The message is now in a readable format and is shown to Alice, and she can be certain Bob sent the message.

Throughout this exchange, only Bob and Alice can read and understand the encrypted message. If someone with access to the server’s database found the message, or someone intercepted their data (like a man-in-the-middle), it would be useless.

For more information on public-private keys, see IBM’s article about Public-key cryptography.

What is the Web Cryptography API?

The Web Cryptography API is a low-level interface recommended by the World Wide Web Consortium (W3C) that allows you to execute cryptographic operations in web applications, such as hashing, signature generation and verification, encryption, and decryption.

To use the Web Crypto API in your Flutter app, use the webcrypto 0.5.2 package. It provides a cross-platform implementation of the Web Crypto API, meaning you can use this package to implement the Web Crypto API in Android, iOS, and Web.

Generate a Cryptographic Key Pair

E2EE requires a cryptographic key pair. A cryptographic key pair consists of a public and private key. Anyone with access to a user's public key can encrypt a message using the public key, which can only be decrypted using the corresponding private key.

This is why a private key should be saved securely, while a public key can be shared freely.

Let’s generate a key pair:

  1. Add the webcrypto 0.5.2 package. Your pubspec.yaml file should look like this:

    Loading code sample...
  2. Write a function that generates a key pair using the ECDH algorithm and the P-256 elliptic curve (P-256 is well-supported and offers the right balance of security and performance).

  • EcdhPrivateKey.generateKey(EllipticCurve.p256): This method returns the KeyPair<EcdhPrivateKey, EcdhPublicKey>. The returned key pair is a combination of EcdhPrivateKey and **EcdhPublicKey**.
  • keyPair.publicKey.exportJsonWebKey(): The public key is exported in the JSON Web Key (JWK) format to store and pass it on to the receiver.
  • keyPair.privateKey.exportJsonWebKey(): In a real-world scenario, you would export the private key to save securely and to keep it private.

For testing purposes, you'll generate a key pair for two users, Bob and Alice, and save these keys securely. You’ll need these keys in the next step. They should look like this:

Bob's

public key:
{kty: EC, crv: P-256, x:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, y: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

private key:
{kty: EC, crv: P-256, x:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, y: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, d:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

-------------------------

Alice's

Public key:
{kty: EC, crv: P-256, x:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, y: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

Private key:
{kty: EC, crv: P-256, x:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, y:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,
d: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}

Generate a Crypto Key

The symmetric Crypto Key is generated using the key pair created in the previous step. You’ll use this key to encrypt and decrypt messages.

For example:

  • Bob will generate a Crypto Key using his private key and Alice’s public key.
  • Alice will generate a Crypto Key using her private key and Bob’s public key.
  • Bob and Alice will use their Crypto Keys to encrypt and decrypt messages sent between them.

Assume that you’re Bob for a moment. Generate a Crypto Key using Alice's public key with the code below:

Let's walk through the code:

  1. The private and public keys are first decoded and then imported from JWK format to EcdhPublicKey and EcdhPrivateKey objects.
  2. The Crypto Key is created using the ecdhPrivateKey.deriveBits(256, ecdhPublicKey) **method.

Similarly, you can generate the Crypto Key for Alice.

Encrypting Messages

Once you’ve generated the Crypto Key, you’re ready to encrypt the message. You’ll use the AES-GCM algorithm for its known security/performance balance and browser availability.

Here's the function to encrypt the message:

Let's walk through the code:

  1. The Crypto Key called derivedBits is imported using AesGcmSecretKey.importRawKey(derivedBits).
  2. The message in String format is then converted into the Uint8List class, which is required by the Algorithm method.
  3. Finally, the aesGcmSecretKey.encryptBytes(data, iv) method encrypts the message.
    1. The "iv" stands for initialization vector (IV). To ensure the encryption’s strength, each encryption process must use a random and distinct IV. It’s included in the message so that the decryption procedure can use it.
  4. The encrypted message is then converted into a String to store in the database.

In the GIF below, the hard-coded message “hello” is encrypted:

Decrypting Messages

Decrypting a message is the opposite of encrypting one. To decrypt a message to a human-readable format, use the code snippet below:

Let's walk through the code:

  1. The Crypto Key called derivedBits is imported using AesGcmSecretKey.importRawKey(derivedBits)
  2. The encrypted message in String format is converted into the Uint8List, required by the Algorithm method.
  3. The encrypted message is decrypted using the aesGcmSecretKey.decryptBytes(message, iv) **method.
  4. The decrypted message is put back in String format using String.fromCharCodes(decryptdBytes) *method.*

Below, you can see how the message is decrypted back to “hello”:

Implement a Chat Feature

Now we’ll start building a chat feature, step by step.

  1. Create a free Stream account.
  1. Once signed in, create an app in the Stream console. Select the Create App button and enter the required details.
  • Note: It's always a good practice to set the Environment as ‘Development’ while working on the app and switch to ‘Production’ when going live.
    1. While developing the app, you may not want your backend to support token authentication. To disable Auth Checks:
    2. Select chat.
    3. Select overview.
    4. Scroll down to the Authentication section and select the Disable Auth Checks toggle button.
  1. Add dependencies. Your pubspec.yaml file should look like this::

    Loading code sample...
  2. Create users. You’ll need users to carry on the conversation.

  1. Create channels. After creating users, you’ll need topic rooms, or channels, so that users can have conversations about a particular topic.

  1. Show your channels with the code below:

  • The ChannelListView widget displays the list of channels and updates the list automatically as it receives new events, like newly added channels or new messages in any channel.

  • Predefined widgets like ChannelHeader(), MessageListView(), and MessageInput() from the Stream library are used to build a chat page. Just like that, you have a simple chat app up and running!

Preparing Your App for End-to-End Encryption

As soon as the app starts and the user connects to Stream Chat, you must store the user’s public key as extra data. Here’s how:To encrypt and decrypt messages, you’ll need to create helper methods. The class in the snippet below contains all the methods you’ll use:

As soon as the app starts and the user connects to Stream Chat, you must store the user’s public key as extra data. Here’s how:.

At this stage, your main() method should look like this:

Note: The AppE2EE().generateKeys() **method is called before connecting the user to get the user’s public key.

Sending Encrypted Messages

Without encryption, the message is stored in the database as is.

Now you’ll use the encrypt() method to encrypt the message.

To do that, make the minor change below in the MessageInput widget.

  • preMessageSending is a parameter that allows your app to process the message before it goes to Stream’s server. Here, you’ve used it to encrypt the message before sending it to Stream’s backend.

Here is how the encrypted message looks in the database:

Showing Decrypted Messages

Now, it’s time to decrypt the message and present it in a human-readable format to the receiver. To do so, sign in as another user and try to decrypt the message.

You’ll customize the MessageListView widget to have your own messagebuilder, including a method to decrypt messages. Here’s how it looks:

In the code above, you used a FutureBuilder to receive a future decrypted message using the AppE2EE().decrypt(message. text) method in String format.

The decrypted message on the receiver side looks like this:

In the figure above, the encrypted message in the database is successfully decrypted and shown in a human-readable format to the receiver.

⚠️Note: For simplification in the demo app, we’ve used the logged-in user's public key to encrypt and decrypt the message. In a real-world scenario, you should use the public key of the receiver to encrypt a message. The receiver's public key can be retrieved from the user’s extraData information as described in the Preparing Your App for End-to-End Encryption section.

That’s it! You’ve successfully built an E2EE Flutter chat app. Go to the E2EE GitHub to see the full source code.

Wrapping Up

In this tutorial, you learned the basics of end-to-end encryption and how to implement it in your Stream Flutter chat app with practical examples. Remember to always consult a security professional when implementing encryption in a production application.