From 8b81d9b5fd862c38b2c7f5ff58c439f0592ade81 Mon Sep 17 00:00:00 2001 From: TatankaConCube Date: Wed, 31 Jul 2024 19:18:22 +0300 Subject: [PATCH] - add E2E encryption for private chats; --- chat_sample/lib/main.dart | 5 +- chat_sample/lib/src/chat_dialog_screen.dart | 35 ++- chat_sample/lib/src/login_screen.dart | 2 + .../src/managers/e2e_encryption_manager.dart | 277 ++++++++++++++++++ chat_sample/lib/src/new_dialog_screen.dart | 9 +- chat_sample/lib/src/settings_screen.dart | 3 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + chat_sample/pubspec.yaml | 3 + 8 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 chat_sample/lib/src/managers/e2e_encryption_manager.dart diff --git a/chat_sample/lib/main.dart b/chat_sample/lib/main.dart index 2d9d5f1..fa35618 100644 --- a/chat_sample/lib/main.dart +++ b/chat_sample/lib/main.dart @@ -15,6 +15,7 @@ import 'src/chat_details_screen.dart'; import 'src/chat_dialog_screen.dart'; import 'src/chat_dialog_resizable_screen.dart'; import 'src/login_screen.dart'; +import 'src/managers/e2e_encryption_manager.dart'; import 'src/managers/push_notifications_manager.dart'; import 'src/select_dialog_screen.dart'; import 'src/settings_screen.dart'; @@ -227,7 +228,9 @@ class _AppState extends State with WidgetsBindingObserver { user.password = facebookAuthSession.token; } } - CubeChatConnection.instance.login(user); + CubeChatConnection.instance.login(user).then((cubeUser){ + E2EEncryptionManager.instance.init(); + }); } else { CubeChatConnection.instance.markActive(); } diff --git a/chat_sample/lib/src/chat_dialog_screen.dart b/chat_sample/lib/src/chat_dialog_screen.dart index 60c21f5..d6c4ca6 100644 --- a/chat_sample/lib/src/chat_dialog_screen.dart +++ b/chat_sample/lib/src/chat_dialog_screen.dart @@ -17,6 +17,7 @@ import 'package:universal_io/io.dart'; import 'package:connectycube_sdk/connectycube_sdk.dart'; import 'managers/chat_manager.dart'; +import 'managers/e2e_encryption_manager.dart'; import 'update_dialog_flow.dart'; import 'utils/api_utils.dart'; import 'utils/consts.dart'; @@ -227,10 +228,14 @@ class ChatScreenState extends State { }); } - void onReceiveMessage(CubeMessage message) { + Future 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); } @@ -352,7 +357,19 @@ class ChatScreenState extends State { void onSendMessage(CubeMessage message) async { log("onSendMessage message= $message"); textEditingController.clear(); - await widget.cubeDialog.sendMessage(message); + + 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, @@ -1008,9 +1025,14 @@ class ChatScreenState extends State { return getMessages( widget.cubeDialog.dialogId!, params.getRequestParameters()) - .then((result) { + .then((result) async { lastPartSize = result!.items.length; + if (widget.cubeDialog.isEncrypted ?? false) { + return await E2EEncryptionManager.instance + .decryptMessages(result.items); + } + return result.items; }) .whenComplete(() {}) @@ -1031,7 +1053,12 @@ class ChatScreenState extends State { return getMessages( widget.cubeDialog.dialogId!, params.getRequestParameters()) - .then((result) { + .then((result) async { + if (widget.cubeDialog.isEncrypted ?? false) { + return await E2EEncryptionManager.instance + .decryptMessages(result!.items); + } + return result!.items; }); } diff --git a/chat_sample/lib/src/login_screen.dart b/chat_sample/lib/src/login_screen.dart index 8333f60..18bba96 100644 --- a/chat_sample/lib/src/login_screen.dart +++ b/chat_sample/lib/src/login_screen.dart @@ -13,6 +13,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:universal_io/io.dart'; import '../firebase_options.dart'; +import 'managers/e2e_encryption_manager.dart'; import 'managers/push_notifications_manager.dart'; import 'phone_auth_flow.dart'; import 'utils/api_utils.dart'; @@ -677,6 +678,7 @@ class LoginPageState extends State { CubeChatConnection.instance.login(user).then((cubeUser) { _isLoginContinues = false; _goDialogScreen(context, cubeUser); + E2EEncryptionManager.instance.init(); }).catchError((error) { _processLoginError(error); }); diff --git a/chat_sample/lib/src/managers/e2e_encryption_manager.dart b/chat_sample/lib/src/managers/e2e_encryption_manager.dart new file mode 100644 index 0000000..9752abd --- /dev/null +++ b/chat_sample/lib/src/managers/e2e_encryption_manager.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:connectycube_sdk/connectycube_sdk.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class E2EEncryptionManager { + static E2EEncryptionManager? _instance; + + StreamSubscription? systemMessagesSubscription; + + E2EEncryptionManager._(); + + static E2EEncryptionManager get instance => + _instance ??= E2EEncryptionManager._(); + + final _secureStorage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true)); + final keyAlgorithm = X25519(); + + init() { + _initCubeChat(); + } + + Future 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); + } + + void _initCubeChat() { + if (CubeChatConnection.instance.isAuthenticated()) { + _initChatListeners(); + } else { + CubeChatConnection.instance.connectionStateStream.listen((state) { + if (CubeChatConnectionState.Ready == state) { + _initChatListeners(); + } + }); + } + } + + _initChatListeners() { + systemMessagesSubscription = CubeChatConnection + .instance.systemMessagesManager?.systemMessagesStream + .listen(onSystemMessageReceived); + } + + Future> encrypt(SecretKey secretKey, String text) async { + final algorithm = AesCtr.with256bits( + macAlgorithm: Hmac.sha256(), + ); + + final secretBox = await algorithm.encrypt( + utf8.encode(text), + secretKey: secretKey, + ); + + return { + 'nonce': base64Encode(secretBox.nonce), + 'content': base64Encode(secretBox.cipherText), + 'mac': base64Encode(secretBox.mac.bytes) + }; + } + + Future decrypt( + SecretKey secretKey, Map secretBox) async { + final algorithm = AesCtr.with256bits( + macAlgorithm: Hmac.sha256(), + ); + + var incomingSecretBox = SecretBox( + base64Decode(secretBox['content']!), + nonce: base64Decode(secretBox['nonce']!), + mac: Mac( + base64Decode(secretBox['mac']!), + ), + ); + + return algorithm + .decrypt( + incomingSecretBox, + secretKey: secretKey, + ) + .then((raw) { + return utf8.decode(raw); + }); + } + + Future 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!); + } + } + } + + Future saveSecretKeyForUserDialog( + SecretKey secretKeyData, String dialogId, int userId) async { + final secretKeyBytes = await secretKeyData.extractBytes(); + await _secureStorage.write( + key: '${userId}_${dialogId}_secretKey', + value: base64Encode(secretKeyBytes)); + } + + Future getSecretKeyForUserDialog( + String dialogId, int userId) async { + final secretKeyBase64 = + await _secureStorage.read(key: '${userId}_${dialogId}_secretKey'); + + if (secretKeyBase64 == null) { + return null; + } + + final secretKeyBytes = base64Decode(secretKeyBase64); + return SecretKeyData(secretKeyBytes); + } + + Future saveKeyPairForUserDialog( + String dialogId, int userId, SimpleKeyPair keyPair) async { + final privateKeyBytes = await keyPair.extractPrivateKeyBytes(); + final publicKeyBytes = (await keyPair.extractPublicKey()).bytes; + + await _secureStorage.write( + key: '${userId}_${dialogId}_privateKey', + value: base64Encode(privateKeyBytes)); + await _secureStorage.write( + key: '${userId}_${dialogId}_publicKey', + value: base64Encode(publicKeyBytes)); + } + + Future getKeyPairForUserDialog( + String dialogId, int userId) async { + final privateKeyBase64 = + await _secureStorage.read(key: '${userId}_${dialogId}_privateKey'); + final publicKeyBase64 = + await _secureStorage.read(key: '${userId}_${dialogId}_publicKey'); + + if (privateKeyBase64 == null || publicKeyBase64 == null) { + return null; + } + + final privateKeyBytes = base64Decode(privateKeyBase64); + final publicKeyBytes = base64Decode(publicKeyBase64); + + final publicKey = SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519); + + return SimpleKeyPairData(privateKeyBytes, + publicKey: publicKey, type: KeyPairType.x25519); + } + + void destroy() { + systemMessagesSubscription?.cancel(); + + _secureStorage.deleteAll( + aOptions: const AndroidOptions(encryptedSharedPreferences: true)); + } + + Future encryptMessage( + CubeMessage originalMessage, String dialogId, int userId) async { + var userDialogSecretKey = await getSecretKeyForUserDialog(dialogId, userId); + + if (userDialogSecretKey == null) return originalMessage; + + return encrypt(userDialogSecretKey, originalMessage.body!) + .then((encryptionData) { + var encryptedMessage = CubeMessage() + ..messageId = originalMessage.messageId + ..dialogId = originalMessage.dialogId + ..body = 'Encrypted message' + ..properties = {...originalMessage.properties, ...encryptionData} + ..attachments = originalMessage.attachments + ..dateSent = originalMessage.dateSent + ..readIds = originalMessage.readIds + ..deliveredIds = originalMessage.deliveredIds + ..viewsCount = originalMessage.viewsCount + ..recipientId = originalMessage.recipientId + ..senderId = originalMessage.senderId + ..markable = originalMessage.markable + ..delayed = originalMessage.delayed + ..saveToHistory = originalMessage.saveToHistory + ..destroyAfter = originalMessage.destroyAfter + ..isRead = originalMessage.isRead + ..reactions = originalMessage.reactions; + + return encryptedMessage; + }); + } + + Future decryptMessage(CubeMessage originalMessage) async { + var userDialogSecretKey = await getSecretKeyForUserDialog( + originalMessage.dialogId!, originalMessage.senderId!); + + if (userDialogSecretKey == null) return originalMessage; + + return decrypt(userDialogSecretKey, originalMessage.properties) + .then((decryptedBody) { + originalMessage.body = decryptedBody; + return originalMessage; + }); + } + + Future> decryptMessages( + List originalMessages) { + return Future.wait(originalMessages + .map((originalMessage) => decryptMessage(originalMessage)) + .toList()); + } +} diff --git a/chat_sample/lib/src/new_dialog_screen.dart b/chat_sample/lib/src/new_dialog_screen.dart index a1ef751..37c25bc 100644 --- a/chat_sample/lib/src/new_dialog_screen.dart +++ b/chat_sample/lib/src/new_dialog_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:connectycube_sdk/connectycube_chat.dart'; +import 'managers/e2e_encryption_manager.dart'; import 'utils/api_utils.dart'; import 'utils/consts.dart'; import 'widgets/common.dart'; @@ -308,9 +309,13 @@ class _BodyLayoutState extends State { selectedUsersArgName: usersToAdd, }); } else { - CubeDialog newDialog = - CubeDialog(CubeDialogType.PRIVATE, occupantsIds: users.toList()); + CubeDialog newDialog = CubeDialog( + CubeDialogType.PRIVATE, + occupantsIds: users.toList(), + )..isEncrypted = true; createDialog(newDialog).then((createdDialog) { + E2EEncryptionManager.instance + .initKeyExchangeForUserDialog(createdDialog.dialogId!, users.first); Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil( 'chat_dialog', (route) => false, arguments: { userArgName: widget.currentUser, diff --git a/chat_sample/lib/src/settings_screen.dart b/chat_sample/lib/src/settings_screen.dart index 0c57b61..8938481 100644 --- a/chat_sample/lib/src/settings_screen.dart +++ b/chat_sample/lib/src/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:connectycube_sdk/connectycube_sdk.dart'; +import 'managers/e2e_encryption_manager.dart'; import 'managers/push_notifications_manager.dart'; import 'utils/api_utils.dart'; import 'utils/consts.dart'; @@ -322,6 +323,7 @@ class _BodyLayoutState extends State { }, ).whenComplete(() { CubeChatConnection.instance.destroy(); + E2EEncryptionManager.instance.destroy(); if (loginType == LoginType.phone) { FirebaseAuth.instance.currentUser ?.unlink(PhoneAuthProvider.PROVIDER_ID); @@ -366,6 +368,7 @@ class _BodyLayoutState extends State { child: const Text("OK"), onPressed: () async { CubeChatConnection.instance.destroy(); + E2EEncryptionManager.instance.destroy(); await SharedPrefs.instance.deleteUser(); await PushNotificationsManager.instance diff --git a/chat_sample/macos/Flutter/GeneratedPluginRegistrant.swift b/chat_sample/macos/Flutter/GeneratedPluginRegistrant.swift index 3ecd3f2..6148170 100644 --- a/chat_sample/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/chat_sample/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audio_session import connectivity_plus +import cryptography_flutter import desktop_webview_auth import device_info_plus import emoji_picker_flutter @@ -32,6 +33,7 @@ import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) DesktopWebviewAuthPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewAuthPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) diff --git a/chat_sample/pubspec.yaml b/chat_sample/pubspec.yaml index 443cd99..51f0350 100644 --- a/chat_sample/pubspec.yaml +++ b/chat_sample/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: flutter_facebook_auth: ^6.1.1 google_sign_in: ^6.2.1 firebase_ui_oauth_google: ^1.3.1 + cryptography: ^2.7.0 + cryptography_flutter: ^2.3.2 + flutter_secure_storage: ^9.2.2 dev_dependencies: flutter_test: