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:

  1. Alice: The message sender.
  2. Bob: The message receiver.

Process Overview:

  1. Key Generation: Both Alice and Bob independently generate their own Identity Key, Pre-key, and Signed Pre-key.
  2. Key Exchange: Alice obtains Bob’s Identity Key and Pre-key, along with one of his One-Time Pre-keys.
  3. Session Establishment: Alice uses Bob’s public key information to establish a secure session with him.
  4. Message Encryption: Alice encrypts her message using the established session with Bob.
  5. Message Transmission: Alice sends the encrypted message to Bob.
  6. Message Decryption: Bob uses his private key information to decrypt the received message.

Dependencies

1
2
3
4
dependencies:
libsignal_protocol_dart: ^0.7.1
hive_flutter: ^1.1.0
get_it: ^8.0.2

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.

2. Implementing a Local Key Store (Using Hive)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import 'package:hive/hive.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';

abstract class IHiveService {
Future<Box> openBox(String boxName);
}

class HiveSignalProtocolStore implements SignalProtocolStore {
final Box<dynamic> box;
final String userId

HiveSignalProtocolStore(this.box, this.userId);

// Initialization method
Future<void> initialize(
IdentityKeyPair identityKeyPair, int registrationId) async {
await box.put('identityKeyPair', identityKeyPair.serialize());
await box.put('registrationId', registrationId);
}

@override
Future<PreKeyRecord> loadPreKey(int preKeyId) async {
final data = box.get('preKey_$preKeyId');
return data != null ? PreKeyRecord.fromBuffer(data) : null;
}

@override
Future<void> storePreKey(int preKeyId, PreKeyRecord preKeyRecord) async {
await box.put('preKey_$preKeyId', preKeyRecord.serialize());
}

@override
Future<void> removePreKey(int preKeyId) async {
await box.delete('preKey_$preKeyId');
}

@override
Future<void> storeSignedPreKey(
int signedPreKeyId,
SignedPreKeyRecord record,
) async {
await box.put('signedPreKey_$signedPreKeyId', record.serialize());
}

@override
Future<SignedPreKeyRecord> loadSignedPreKey(int signedPreKeyId) async {
final data = box.get('signedPreKey_$signedPreKeyId');
return data != null ? SignedPreKeyRecord.fromBuffer(data) : null;
}

@override
Future<void> removeSignedPreKey(int signedPreKeyId) async {
await box.delete('signedPreKey_$signedPreKeyId');
}

@override
Future<SessionRecord> loadSession(SignalProtocolAddress address) async {
final data = box.get('session_${address.getName()}_${address.getDeviceId()}');
return data != null ? SessionRecord.fromBuffer(data) : SessionRecord();
}

@override
Future<List<int>> getSubDeviceSessions(String name) async {
final keys = box.keys.whereType<String>().where((key) => key.startsWith('session_$name')).toList();
return keys.map((key) => int.parse(key.split('_').last)).toList();
}

@override
Future<void> storeSession(SignalProtocolAddress address, SessionRecord record) async {
await box.put('session_${address.getName()}_${address.getDeviceId()}', record.serialize());
}

@override
Future<void> deleteSession(SignalProtocolAddress address) async {
await box.delete('session_${address.getName()}_${address.getDeviceId()}');
}

@override
Future<void> deleteAllSessions(String name) async {
final keysToRemove = box.keys.whereType<String>().where((key) => key.startsWith('session_$name')).toList();
await box.deleteAll(keysToRemove);
}

@override
Future<IdentityKeyPair> getIdentityKeyPair() async {
final data = box.get('identityKeyPair');
return data != null ? IdentityKeyPair.fromBuffer(data) : null;
}

@override
Future<int> getLocalRegistrationId() async {
return box.get('registrationId') as int?;
}

@override
Future<int> getPreKeyCount() async {
return box.keys.whereType<String>().where((key) => key.startsWith('preKey_')).length;
}

@override
Future<int> getSignedPreKeyCount() async {
return box.keys.whereType<String>().where((key) => key.startsWith('signedPreKey_')).length;
}
}

// 获取 Hive 存储的函数
Future<HiveSignalProtocolStore> _getStore(String userId) async {
final store = _stores[userId];
if (store != null) {
return store;
}

final box = await getIt<IHiveService>().openBox('signal_store_$userId');
final newStore = HiveSignalProtocolStore(box, userId);
_stores[userId] = newStore;
return newStore;
}

3. Generating Key Bundles (Each Generated by Alice and Bob)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import 'dart:convert';
import 'dart:typed_data';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';

// 将密钥上传到服务器的函数
Future<void> uploadKeysToServer(IdentityKeyPair identityKeyPair, SignedPreKeyRecord signedPreKey, List<PreKeyRecord> preKeys, int registrationId) async { ... }

final selfId = 'userId';

final store = _getStore(selfId);

// 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):

1
2
3
DH1 = DH(IK_A, SPK_B)
DH2 = DH(EK_A, IK_B)
DH3 = DH(EK_A, SPK_B)

Then, uses the key derivation function (KDF) to combine these DH values to generate a shared key (SK):

1
SK = KDF(DH1 || DH2 || DH3)

Code Example (Alice’s Side):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import 'dart:convert';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';

// Function to retrieve Bob's key bundle from the server
Future<Map<String, String>> getKeyBundleFromServer(String bobId) async { ... }

const String 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);

// Build PreKeyBundle
final bundle = PreKeyBundle(
int.parse(bundleData['registrationId']!),
1,
preKeyId,
preKeyPublic,
signedPreKeyId,
signedPreKeyPublic,
signedPreKeySignature,
identityKeyPublic,
);

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.

Code Example (Alice’s Side):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import 'dart:convert';
import 'dart:typed_data';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';

const String remoteUserId = 'bob_user_id'; // Replace with Bob's actual user ID
const String 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();

// Send message
await sendMessage(encryptedMessage, type);
print('Encrypted message: $encryptedMessage');
print('Message type: $type');

6. Message Decryption

Bob uses his private key information to decrypt the message received from Alice.

Code Example (Bob’s Side):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import 'dart:convert';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';

const String aliceUserId = 'alice_user_id'; // Replace with Alice's actual user ID

final selfId = 'userId';
final bobStore = _getStore(selfId);

class ReceivedMessage {
final String encryptedMessage;
final int type;

ReceivedMessage(this.encryptedMessage, this.type);
}

// 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,
);

// Decrypt message
try {
if (type == 3) { // PreKeySignalMessage
final preKeySignalMessage = PreKeySignalMessage.fromBuffer(base64Decode(encryptedMessage));
final decryptedMessage = await sessionCipher.decrypt(preKeySignalMessage);
print("Decrypted message (PreKey): ${utf8.decode(decryptedMessage)}");
} else if (type == 2) { // SignalMessage
final signalMessage = SignalMessage.fromSerialized(base64Decode(encryptedMessage));
final decryptedMessage = await sessionCipher.decryptFromSignal(signalMessage);
print("Decrypted message (Signal): ${utf8.decode(decryptedMessage)}");
} else {
print("Unknown message type: $type");
}
} catch (e) {
print("Decryption error: $e");
}

Forward Secrecy

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.

References


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 !