Implementing End-to-End Encryption in Chat Applications
Posted by Loscoy on
2025-03-20
Estimated Reading Time 12 Minutes
Words 2k In Total
Viewed Times
Have you ever used instant messaging applications like WhatsApp, Signal, or Telegram? A key security feature they offer is end-to-end encryption (E2EE). This ensures that messages are encrypted directly between the sender and recipient, making the content inaccessible to servers or any third parties.
Understanding End-to-End Encryption
End-to-End Encryption (E2EE) is a cryptographic method that guarantees only the communicating participants can decrypt the messages. In an E2EE system, messages are encrypted at the sender’s device and decrypted only on the recipient’s device, without any intermediate server or third party having access to the plaintext content.
Scope of End-to-End Encryption
E2EE can be applied to various forms of communication, including:
Text messages
Images
Audio messages
Video messages
While the specific encryption process might vary depending on the data format, the underlying principles remain consistent.
Performance Considerations of E2EE
Implementing E2EE introduces a slight increase in message latency due to the time required for encryption and decryption processes. This added overhead is generally acceptable for the enhanced security it provides.
End-to-End Encryption Flow Using the Signal Protocol
This section outlines the implementation of E2EE using the widely respected Signal Protocol.
Participants:
Alice: The message sender.
Bob: The message receiver.
Process Overview:
Key Generation: Both Alice and Bob independently generate their own Identity Key, Pre-key, and Signed Pre-key.
Key Exchange: Alice obtains Bob’s Identity Key and Pre-key, along with one of his One-Time Pre-keys.
Session Establishment: Alice uses Bob’s public key information to establish a secure session with him.
Message Encryption: Alice encrypts her message using the established session with Bob.
Message Transmission: Alice sends the encrypted message to Bob.
Message Decryption: Bob uses his private key information to decrypt the received message.
Detailed Steps and Code Examples (Dart Implementation)
This implementation utilizes the libsignal_protocol_dart library and hive for local key storage.
1. Key Generation
Both Alice and Bob need to generate the following keys:
Identity Key: Typically generated during user registration and remains unchanged throughout the account’s lifecycle. It is used to verify the user’s identity and sign other keys.
Signed Pre-key: Signed by the identity key to prove its legitimacy, it is used to establish the initial encrypted session, and needs to be updated periodically.
Pre-key: A one-time-use key pair, discarded after one use. Each session uses a unique pre-key, which is destroyed after use to ensure perfect forward secrecy. Servers typically store multiple pre-keys. When the server runs out of pre-keys, the client needs to generate new ones.
// Generate new identity key pair and registration ID final identityKeyPair = generateIdentityKeyPair(); final registrationId = generateRegistrationId(false);
// Initialize the store with identity key pair and registration ID await store.initialize(identityKeyPair, registrationId);
// Generate pre-keys // This is just an example, in real applications you should generate keys according to your specific requirements final preKeys = generatePreKeys(0, 100); // Store pre-keys for (final preKey in preKeys) { await store.storePreKey(preKey.id, preKey); }
// Generate signed pre-key final signedPreKey = generateSignedPreKey(identityKeyPair, 0); // Store signed pre-key await store.storeSignedPreKey(signedPreKey.id, signedPreKey);
// Upload keys to the server await uploadKeysToServer(identityKeyPair, signedPreKey, preKeys, registrationId);
4. Establishing a Session
Alice uses Bob’s Identity Key and Pre-key (obtained from the server) to establish a session with Bob. If the server runs out of pre-keys, the server will not return PreKey, and Alice needs to calculate the following Diffie-Hellman (DH) values (reference: Signal Protocol Documentation - Sending the Initial Message):
// Function to retrieve Bob's key bundle from the server Future<Map<String, String>> getKeyBundleFromServer(String bobId) async { ... }
constString remoteUserId = 'bob_user_id'; // Replace with Bob's actual user ID
final selfId = 'userId'; final aliceStore = _getStore(selfId);
// Get Bob's identity key and pre-key bundle from the server // final bundleData = await getKeyBundleFromServer(remoteUserId); final bundleData = { // Mock data for demonstration 'registrationId': '12345', 'preKey': base64Encode(PreKeyRecord(0, KeyPair.generate()).serialize()), 'signedPreKey': base64Encode(SignedPreKeyRecord(0, KeyPair.generate(), Uint8List.fromList(utf8.encode('signature'))).serialize()), 'identityKey': base64Encode(KeyPair.generate().publicKey.serialize()), };
final remoteAddress = SignalProtocolAddress(remoteUserId, 1);
// Bob's pre-key final preKey = bundleData['preKey']!; final preKeyRecord = PreKeyRecord.fromBuffer(base64Decode(preKey)); final preKeyId = preKeyRecord.preKeyId; final preKeyPublic = preKeyRecord.getKeyPair().publicKey;
// Bob's signed pre-key final signedPreKey = SignedPreKeyRecord.fromSerialized( base64Decode(bundleData['signedPreKey']!), ); final signedPreKeyId = signedPreKey.id; final signedPreKeyPublic = signedPreKey.getKeyPair().publicKey; final signedPreKeySignature = signedPreKey.signature;
// Bob's identity key final identityKeyPublic = IdentityKey.fromBytes(base64Decode(bundleData['identityKey']!), 0);
final sessionBuilder = SessionBuilder.fromSignalStore( aliceStore, remoteAddress, ); // Use PreKeyBundle to establish a session // This session will be stored in Alice's local database await sessionBuilder.processPreKeyBundle(bundle);
5. Message Encryption
Alice uses the established session with Bob to encrypt the message.
constString remoteUserId = 'bob_user_id'; // Replace with Bob's actual user ID constString messageToSend = 'Hello Bob!';
final selfId = 'userId'; final aliceStore = _getStore(selfId);
// Get remote address final remoteAddress = SignalProtocolAddress(remoteUserId, 1); // Get session cipher final sessionCipher = SessionCipher.fromStore( aliceStore, remoteAddress, );
// Encrypt message final ciphertext = await sessionCipher.encrypt( Uint8List.fromList(utf8.encode(messageToSend)), );
// Encrypted message (base64 encoded for easy transmission) final encryptedMessage = base64Encode(ciphertext.serialize());
// Message type (SignalMessage or PreKeySignalMessage) final type = ciphertext.getType();
// Simulate receiving a message from Alice final message = ReceivedMessage( 'AgAAAAEAAAAAAAD...', // Replace with an actual encrypted message 1, // Replace with the actual message type (PreKeySignalMessage or SignalMessage) );
final encryptedMessage = message.encryptedMessage; final type = message.type;
// Get remote address final remoteAddress = SignalProtocolAddress(aliceUserId, 1); // Get session cipher final sessionCipher = SessionCipher.fromStore( store, remoteAddress, );
The Signal Protocol achieves forward secrecy by utilizing One-Time Pre-keys. Each time Alice establishes a session with Bob, a new one-time pre-key of Bob’s is used. This ensures that even if an attacker gains access to Bob’s long-term private key, they cannot decrypt past messages encrypted using previously used one-time pre-keys.
Double Ratchet Algorithm
Double Ratchet algorithm, which is used by two parties to exchange encrypted messages based on a shared secret key. The parties derive new keys for every Double Ratchet message so that earlier keys cannot be calculated from later ones. The parties also send Diffie-Hellman public values attached to their messages. The results of Diffie-Hellman calculations are mixed into the derived keys so that later keys cannot be calculated from earlier ones. These properties give some protection to earlier or later encrypted messages in case of a compromise of a party’s keys.
Summary
The Signal Protocol provides a robust and secure end-to-end encryption solution by combining Identity Keys, Pre-keys, Signed Pre-keys, and One-Time Pre-keys. The libsignal_protocol_dart library offers the necessary tools to implement this protocol within Dart applications. Remember that the provided code snippets are illustrative and require proper integration with your application’s user management, server communication, and key management strategies.
If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them. Thank you !