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:
-
Add the webcrypto 0.5.2 package. Your
pubspec.yaml
file should look like this:yaml12345dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 webcrypto: ^0.5.2 #new
- 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).
Future<void> _generateKeys() async { //1. Generate keys KeyPair<EcdhPrivateKey, EcdhPublicKey> keyPair = await EcdhPrivateKey. (EllipticCurve.p256); Map<String, dynamic> publicKeyJwk = await keyPair.publicKey.exportJsonWebKey(); Map<String, dynamic> privateKeyJwk = await keyPair.privateKey.exportJsonWebKey(); }
EcdhPrivateKey.generateKey(EllipticCurve.p256)
: This method returns theKeyPair<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:
Future<void> _deriveKey() async { //1. Alice's public key Map<String, dynamic> publicjwk = json.decode( '{"kty": "EC", "crv": "P-256", "x": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "y": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}'); EcdhPublicKey ecdhPublicKey = await EcdhPublicKey. (publicjwk, EllipticCurve.p256); //2. Bob's private key Map<String, dynamic> privatejwk = json.decode( '{"kty": "EC", "crv": "P-256", "x": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "y": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "d": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}'); EcdhPrivateKey ecdhPrivateKey = await EcdhPrivateKey. (privatejwk, EllipticCurve.p256); //3. Generating cryptokey called deriveBits Uint8List derivedBits = await ecdhPrivateKey.deriveBits(256, ecdhPublicKey); }
Let's walk through the code:
- The private and public keys are first decoded and then imported from JWK format to
EcdhPublicKey
andEcdhPrivateKey
objects. - 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:
final Uint8List iv = Uint8List.fromList('Initialization Vector'.codeUnits); Future<String> encrypt(String message) async { // 1. aesGcmSecretKey = await AesGcmSecretKey. (derivedBits); // 2. List<int> list = message.codeUnits; Uint8List data = Uint8List.fromList(list); // 3. Uint8List encryptedBytes = await aesGcmSecretKey.encryptBytes(data, iv); // 4. String encryptedString = String.fromCharCodes(encryptedBytes); print('encryptedString $encryptedString'); return encryptedString; }
Let's walk through the code:
- The Crypto Key called derivedBits is imported using
AesGcmSecretKey.importRawKey(derivedBits)
. - The message in String format is then converted into the Uint8List class, which is required by the Algorithm method.
- Finally, the
aesGcmSecretKey.encryptBytes(data, iv)
method encrypts the message.- 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.
- The encrypted message is then converted into a String to store in the database.
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:
Future<String> decrypt(String encryptedMessage) async { // 1. aesGcmSecretKey = await AesGcmSecretKey. (derivedBits); // 2. List<int> message = Uint8List.fromList(encryptedMessage.codeUnits); // 3. Uint8List decryptdBytes = await aesGcmSecretKey.decryptBytes(message, iv); // 4. String decryptdString = String.fromCharCodes(decryptdBytes); print('decryptdString $decryptdString'); return decryptdString; }
Let's walk through the code:
- The Crypto Key called derivedBits is imported using
AesGcmSecretKey.importRawKey(derivedBits)
- The encrypted message in String format is converted into the Uint8List, required by the Algorithm method.
- The encrypted message is decrypted using the
aesGcmSecretKey.decryptBytes(message, iv)
**method. - The decrypted message is put back in String format using
String.fromCharCodes(decryptdBytes)
*method.*
Implement a Chat Feature
Now we’ll start building a chat feature, step by step.
- Create a free Stream account.
- 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.
- While developing the app, you may not want your backend to support token authentication. To disable Auth Checks:
- Select chat.
- Select overview.
- Scroll down to the Authentication section and select the Disable Auth Checks toggle button.
-
Add dependencies. Your
pubspec.yaml
file should look like this::yaml1234dependencies: flutter: sdk: flutter stream_chat_flutter: ^1.5.0
- Create users. You’ll need users to carry on the conversation.
import 'package:flutter/material.dart'; import 'package:flutter_desktop_chat_app/responsive_builder.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; //Before void main() { runApp(MyApp()); } //---------------------------- //After Future<void> main() async { //Step 1 final client = StreamChatClient( 'YOUR-KEY', logLevel: Level._INFO_, ); //Step 2 await client.connectUser( User( id: 'Harry', extraData: { 'image': 'https://picsum.photos/id/1005/200/300', }, ), client.devToken('Harry'), ); runApp(MyApp( client: client, )); } class MyApp extends StatelessWidget { // This widget is the root of your application. const MyApp({Key key, this.client}) : super(key: key); final StreamChatClient client; @override Widget build(BuildContext context) { // Step 3 return StreamChat( client: client, child: MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors._blue_, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ), ); } }
- Create channels. After creating users, you’ll need topic rooms, or channels, so that users can have conversations about a particular topic.
Future<void> main() async { //Create stream clien //Create users //Create channels //Step 1 final channel = client.channel( "messaging", id: "guitarist", extraData: { "name": "Guitarist", "image": "https://source.unsplash.com/5HltXT-6Vgw", "members": ['Jeffery', 'Jhon', 'Mike'], }, ); //Step 2 await channel.watch(); runApp(MyApp( client: client, )); }
- Show your channels with the code below:
Widget build(BuildContext context) { return Scaffold( //backgroundColor: Colors.white, body: ChannelsBloc( child: ChannelListView( onChannelTap: onItemTap != null ? (channel, _) { onItemTap(channel); } : null, filter: { 'members': { '\$in': [StreamChat.of(context).user.id], } }, sort: [SortOption('last_message_at')], pagination: PaginationParams( limit: 20, ), channelWidget: ChannelPage(), ), ), ); }
- 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.
Widget build(BuildContext context) { return Scaffold( appBar: ChannelHeader( showBackButton: showBackButton, ), body: Column( children: <Widget>[ Expanded( child: MessageListView(), ), MessageInput( disableAttachments: true, ), ], ), ); }
- 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:
import 'dart:typed_data'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:webcrypto/webcrypto.dart'; class AppE2EE { static final AppE2EE = AppE2EE._internal(); factory AppE2EE() { return ; } AppE2EE._internal(); KeyPair<EcdhPrivateKey, EcdhPublicKey> keyPair; Uint8List derivedBits; AesGcmSecretKey aesGcmSecretKey; final Uint8List iv = Uint8List.fromList('Initialization Vector'.codeUnits); Future<void> generateKeys() async { final prefs = await SharedPreferences. (); String derivedBitsString = (prefs.getString('derivedBits') ?? ''); if (derivedBitsString.isNotEmpty) { derivedBits = Uint8List.fromList(derivedBitsString.codeUnits); print('derivedBits present'); return; } // 1. Generate keys keyPair = await EcdhPrivateKey. (EllipticCurve.p256); Map<String, dynamic> publicKeyJwk = await keyPair.publicKey.exportJsonWebKey(); Map<String, dynamic> privateKeyJwk = await keyPair.privateKey.exportJsonWebKey(); print('keypair $keyPair, $publicKeyJwk, $privateKeyJwk'); deriveBits(); } Future<void> deriveBits() async { // 2. Derive bits derivedBits = await keyPair.privateKey.deriveBits(256, keyPair.publicKey); final prefs = await SharedPreferences. (); prefs.setString('derivedBits', String.fromCharCodes(derivedBits)); print('derivedBits $derivedBits'); } Future<String> encrypt(String message) async { // 3. Encrypt aesGcmSecretKey = await AesGcmSecretKey. (derivedBits); List<int> list = message.codeUnits; Uint8List data = Uint8List.fromList(list); Uint8List encryptedBytes = await aesGcmSecretKey.encryptBytes(data, iv); String encryptedString = String.fromCharCodes(encryptedBytes); print('encryptedString $encryptedString'); return encryptedString; } Future<String> decrypt(String encryptedMessage) async { // 4. Decrypt aesGcmSecretKey = await AesGcmSecretKey. (derivedBits); List<int> message = Uint8List.fromList(encryptedMessage.codeUnits); Uint8List decryptdBytes = await aesGcmSecretKey.decryptBytes(message, iv); String decryptdString = String.fromCharCodes(decryptdBytes); print('decryptdString $decryptdString'); return decryptdString; } }
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:.
await AppE2EE().generateKeys(); Map<String, dynamic> publicKeyJwk = await AppE2EE().keyPair.publicKey.exportJsonWebKey(); -------------------------------------- await client.connectUser( User( id: 'Pinkesh', extraData: { 'image': 'https://picsum.photos/id/1025/200/300', 'publicKey': publicKeyJwk, }, ), client.devToken('Pinkesh'), );
At this stage, your main()
method should look like this:
Future<void> main() async { WidgetsFlutterBinding. (); final client = StreamChatClient( 'ue75xxvdjwwa', logLevel: Level. , ); await AppE2EE().generateKeys(); Map<String, dynamic> publicKeyJwk = await AppE2EE().keyPair.publicKey.exportJsonWebKey(); await client.connectUser( User( id: 'Pinkesh', extraData: { 'image': 'https://picsum.photos/id/1025/200/300', 'publicKey': publicKeyJwk, }, ), client.devToken('Pinkesh'), ); final channel = client.channel( "messaging", id: "guitarist", extraData: { "name": "Guitarist", "image": "https://source.unsplash.com/5HltXT-6Vgw", "members": ['Pinkesh', 'Jhon'], }, ); await channel.watch(); runApp(MyApp( client: client, )); }
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.
MessageInput( disableAttachments: true, preMessageSending: (Message message) async { String encryptedMessage = await AppE2EE().encrypt(message.text); Message newmessage = message.copyWith(text: encryptedMessage); return newmessage; }, ),
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:
MessageListView( messageBuilder: _messageBuilder, ) ----------- Widget _messageBuilder( BuildContext context, MessageDetails details, List<Message> messages, ) { Message message = details.message; final isCurrentUser = StreamChat. (context).user.id == message.user.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors. : Colors. ; return FutureBuilder<String>( future: AppE2EE().decrypt(message.text), // a Future<String> or null builder: (BuildContext context, AsyncSnapshot<String> snapshot) { switch (snapshot.connectionState) { case ConnectionState.none: case ConnectionState.waiting: default: if (snapshot.hasError) return Text('Error: ${snapshot.error}'); else return Padding( padding: EdgeInsets.all(5.0), child: Container( decoration: BoxDecoration( border: Border.all(color: color, width: 1), borderRadius: BorderRadius.all( Radius.circular(5.0), ), ), child: ListTile( title: Text( snapshot.data, textAlign: textAlign, ), ), ), ); } }, ); }
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.