From f5b493f9bd6044d1d4d4196613988f12a064faf7 Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sat, 15 Feb 2020 07:48:41 +0000 Subject: [PATCH] [SDK] Add dart-olm library and update CI --- .gitignore | 1 + .gitlab-ci.yml | 20 ++ lib/famedlysdk.dart | 1 + lib/src/client.dart | 473 +++++++++++++++++++++++++++- lib/src/room.dart | 243 +++++++++++++- lib/src/store_api.dart | 6 +- lib/src/timeline.dart | 13 + lib/src/utils/device_keys_list.dart | 3 + lib/src/utils/session_key.dart | 38 +++ lib/src/utils/to_device_event.dart | 25 ++ prepare.sh | 23 ++ pubspec.lock | 24 +- pubspec.yaml | 7 +- test.sh | 2 + test/client_test.dart | 270 +++++++++++++++- test/fake_matrix_api.dart | 40 ++- test/fake_store.dart | 89 ++++++ test/room_test.dart | 74 +++++ test_driver/famedlysdk_test.dart | 131 ++++++++ 19 files changed, 1446 insertions(+), 37 deletions(-) create mode 100644 lib/src/utils/session_key.dart create mode 100644 lib/src/utils/to_device_event.dart create mode 100644 prepare.sh create mode 100644 test.sh create mode 100644 test/fake_store.dart create mode 100644 test_driver/famedlysdk_test.dart diff --git a/.gitignore b/.gitignore index 9d7edcf8..79894283 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .buildlog/ .history .svn/ +native/ # IntelliJ related *.iml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5bb936da..6deeb881 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,6 +7,26 @@ variables: JEKYLL_ENV: production coverage: + image: debian:testing + stage: coverage + coverage: '/^\s+lines.+: (\d+.\d*%)/' + dependencies: [] + script: + - apt update + - apt install -y curl gnupg2 git + - curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - + - curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list + - apt update + - apt install -y dart chromium lcov libolm3 + - ln -s /usr/lib/dart/bin/pub /usr/bin/ + - useradd -m test + - chown -R 'test:' '.' + - chmod +x ./prepare.sh + - chmod +x ./test.sh + - su -c ./prepare.sh test + - su -c ./test.sh test + +coverage_without_olm: image: cirrusci/flutter stage: coverage coverage: '/^\s+lines.+: (\d+.\d*%)/' diff --git a/lib/famedlysdk.dart b/lib/famedlysdk.dart index 8e6649d9..c2b7de4e 100644 --- a/lib/famedlysdk.dart +++ b/lib/famedlysdk.dart @@ -36,6 +36,7 @@ export 'package:famedlysdk/src/utils/profile.dart'; export 'package:famedlysdk/src/utils/push_rules.dart'; export 'package:famedlysdk/src/utils/receipt.dart'; export 'package:famedlysdk/src/utils/states_map.dart'; +export 'package:famedlysdk/src/utils/to_device_event.dart'; export 'package:famedlysdk/src/utils/turn_server_credentials.dart'; export 'package:famedlysdk/src/account_data.dart'; export 'package:famedlysdk/src/client.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 35e93a48..1e08fc34 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -24,6 +24,7 @@ import 'dart:async'; import 'dart:core'; +import 'package:canonical_json/canonical_json.dart'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/src/account_data.dart'; import 'package:famedlysdk/src/presence.dart'; @@ -32,7 +33,9 @@ import 'package:famedlysdk/src/sync/user_update.dart'; import 'package:famedlysdk/src/utils/device_keys_list.dart'; import 'package:famedlysdk/src/utils/matrix_file.dart'; import 'package:famedlysdk/src/utils/open_id_credentials.dart'; +import 'package:famedlysdk/src/utils/to_device_event.dart'; import 'package:famedlysdk/src/utils/turn_server_credentials.dart'; +import 'package:olm/olm.dart' as olm; import 'package:pedantic/pedantic.dart'; import 'room.dart'; import 'event.dart'; @@ -117,6 +120,16 @@ class Client { List get rooms => _rooms; List _rooms = []; + olm.Account _olmAccount; + + /// Returns the base64 encoded keys to store them in a store. + /// This String should **never** leave the device! + String get pickledOlmAccount => + encryptionEnabled ? _olmAccount.pickle(userID) : null; + + /// Whether this client supports end-to-end encryption using olm. + bool get encryptionEnabled => _olmAccount != null; + /// Warning! This endpoint is for testing only! set rooms(List newList) { print("Warning! This endpoint is for testing only!"); @@ -284,6 +297,9 @@ class Client { newDeviceID: response["device_id"], newMatrixVersions: matrixVersions, newLazyLoadMembers: lazyLoadMembers); + if (await this._uploadKeys(uploadDeviceKeys: true) == false) { + await this.logout(); + } } return response; } @@ -319,13 +335,18 @@ class Client { loginResp.containsKey("access_token") && loginResp.containsKey("device_id")) { await this.connect( - newToken: loginResp["access_token"], - newUserID: loginResp["user_id"], - newHomeserver: homeserver, - newDeviceName: initialDeviceDisplayName ?? "", - newDeviceID: loginResp["device_id"], - newMatrixVersions: matrixVersions, - newLazyLoadMembers: lazyLoadMembers); + newToken: loginResp["access_token"], + newUserID: loginResp["user_id"], + newHomeserver: homeserver, + newDeviceName: initialDeviceDisplayName ?? "", + newDeviceID: loginResp["device_id"], + newMatrixVersions: matrixVersions, + newLazyLoadMembers: lazyLoadMembers, + ); + if (await this._uploadKeys(uploadDeviceKeys: true) == false) { + await this.logout(); + return false; + } return true; } return false; @@ -564,6 +585,11 @@ class Client { /// Outside of rooms there are account updates like account_data or presences. final StreamController onUserEvent = StreamController.broadcast(); + /// The onToDeviceEvent is called when there comes a new to device event. It is + /// already decrypted if necessary. + final StreamController onToDeviceEvent = + StreamController.broadcast(); + /// Called when the login state e.g. user gets logged out. final StreamController onLoginStateChanged = StreamController.broadcast(); @@ -646,6 +672,7 @@ class Client { List newMatrixVersions, bool newLazyLoadMembers, String newPrevBatch, + String newOlmAccount, }) async { this._accessToken = newToken; this._homeserver = newHomeserver; @@ -656,9 +683,46 @@ class Client { this._lazyLoadMembers = newLazyLoadMembers; this.prevBatch = newPrevBatch; + // Try to create a new olm account or restore a previous one. + if (newOlmAccount == null) { + try { + await olm.init(); + this._olmAccount = olm.Account(); + this._olmAccount.create(); + } catch (_) { + this._olmAccount = null; + } + } else { + try { + await olm.init(); + this._olmAccount = olm.Account(); + this._olmAccount.unpickle(userID, newOlmAccount); + } catch (_) { + this._olmAccount = null; + } + } + if (this.storeAPI != null) { await this.storeAPI.storeClient(); _userDeviceKeys = await this.storeAPI.getUserDeviceKeys(); + final String olmSessionPickleString = + await storeAPI.getItem("/clients/$userID/olm-sessions"); + if (olmSessionPickleString != null) { + final Map> pickleMap = + json.decode(olmSessionPickleString); + for (var entry in pickleMap.entries) { + for (String pickle in entry.value) { + _olmSessions[entry.key] = []; + try { + olm.Session session = olm.Session(); + session.unpickle(userID, pickle); + _olmSessions[entry.key].add(session); + } catch (e) { + print("[LibOlm] Could not unpickle olm session: " + e.toString()); + } + } + } + } if (this.store != null) { this._rooms = await this.store.getRoomList(onlyLeft: false); this._sortRooms(); @@ -852,6 +916,7 @@ class Client { } } + /// Use this method only for testing utilities! void handleSync(dynamic sync) { if (sync["rooms"] is Map) { if (sync["rooms"]["join"] is Map) { @@ -874,22 +939,57 @@ class Client { } if (sync["to_device"] is Map && sync["to_device"]["events"] is List) { - _handleGlobalEvents(sync["to_device"]["events"], "to_device"); + _handleToDeviceEvents(sync["to_device"]["events"]); } if (sync["device_lists"] is Map) { _handleDeviceListsEvents(sync["device_lists"]); } + if (sync["device_one_time_keys_count"] is Map) { + _handleDeviceOneTimeKeysCount(sync["device_one_time_keys_count"]); + } onSync.add(sync); } + void _handleDeviceOneTimeKeysCount( + Map deviceOneTimeKeysCount) { + if (!encryptionEnabled) return; + // Check if there are at least half of max_number_of_one_time_keys left on the server + // and generate and upload more if not. + if (deviceOneTimeKeysCount["signed_curve25519"] is int) { + final int oneTimeKeysCount = deviceOneTimeKeysCount["signed_curve25519"]; + if (oneTimeKeysCount < (_olmAccount.max_number_of_one_time_keys() / 2)) { + // Generate and upload more one time keys: + _uploadKeys(); + } + } + } + + /// Clears the outboundGroupSession from all rooms where this user is + /// participating. Should be called when the user's devices list has changed. + void _clearOutboundGroupSessionsByUserId(String userId) { + for (Room room in rooms) { + if (!room.encrypted) continue; + room.requestParticipants().then((List users) { + if (users.indexWhere((u) => + u.id == userId && + [Membership.join, Membership.invite].contains(u.membership)) != + -1) { + room.clearOutboundGroupSession(); + } + }); + } + } + void _handleDeviceListsEvents(Map deviceLists) { if (deviceLists["changed"] is List) { for (final userId in deviceLists["changed"]) { + _clearOutboundGroupSessionsByUserId(userId); if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys[userId].outdated = true; } } for (final userId in deviceLists["left"]) { + _clearOutboundGroupSessionsByUserId(userId); if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys.remove(userId); } @@ -897,6 +997,30 @@ class Client { } } + void _handleToDeviceEvents(List events) { + for (int i = 0; i < events.length; i++) { + bool isValid = events[i] is Map && + events[i]["type"] is String && + events[i]["sender"] is String && + events[i]["content"] is Map; + if (!isValid) { + print("[Sync] Invalid To Device Event! ${events[i]}"); + continue; + } + ToDeviceEvent toDeviceEvent = ToDeviceEvent.fromJson(events[i]); + if (toDeviceEvent.type == "m.room.encrypted") { + try { + toDeviceEvent = decryptToDeviceEvent(toDeviceEvent); + } catch (e) { + print("[LibOlm] Could not decrypt to device event: " + e.toString()); + toDeviceEvent = ToDeviceEvent.fromJson(events[i]); + } + } + _updateRoomsByToDeviceEvent(toDeviceEvent); + onToDeviceEvent.add(toDeviceEvent); + } + } + void _handleRooms(Map rooms, Membership membership) { rooms.forEach((String id, dynamic room) async { // calculate the notification counts, the limitedTimeline and prevbatch @@ -1052,8 +1176,24 @@ class Client { type: type, content: event, ); - _updateRoomsByEventUpdate(update); this.store?.storeEventUpdate(update); + if (event["type"] == "m.room.encrypted") { + Room room = getRoomById(roomID); + try { + Event decrpytedEvent = + room.decryptGroupMessage(Event.fromJson(event, room)); + event = decrpytedEvent.toJson(); + update = EventUpdate( + eventType: event["type"], + roomID: roomID, + type: type, + content: event, + ); + } catch (e) { + print("[LibOlm] Could not decrypt megolm event: " + e.toString()); + } + } + _updateRoomsByEventUpdate(update); onEvent.add(update); if (event["type"] == "m.call.invite") { @@ -1094,6 +1234,7 @@ class Client { roomAccountData: {}, client: this, ); + newRoom.restoreGroupSessionKeys(); rooms.insert(position, newRoom); } // If the membership is "leave" then remove the item and stop here @@ -1172,6 +1313,22 @@ class Client { if (eventUpdate.type == "timeline") _sortRooms(); } + void _updateRoomsByToDeviceEvent(ToDeviceEvent toDeviceEvent) { + try { + switch (toDeviceEvent.type) { + case "m.room_key": + Room room = getRoomById(toDeviceEvent.content["room_id"]); + if (room != null && toDeviceEvent.content["session_id"] is String) { + final String sessionId = toDeviceEvent.content["session_id"]; + room.setSessionKey(sessionId, toDeviceEvent.content); + } + break; + } + } catch (e) { + print(e); + } + } + bool _sortLock = false; /// The compare function how the rooms should be sorted internally. By default @@ -1253,4 +1410,302 @@ class Client { } await this.storeAPI?.storeUserDeviceKeys(userDeviceKeys); } + + String get fingerprintKey => encryptionEnabled + ? json.decode(_olmAccount.identity_keys())["ed25519"] + : null; + String get identityKey => encryptionEnabled + ? json.decode(_olmAccount.identity_keys())["curve25519"] + : null; + + /// Adds a signature to this json from this olm account. + Map signJson(Map payload) { + if (!encryptionEnabled) throw ("Encryption is disabled"); + final Map unsigned = payload["unsigned"]; + final Map signatures = payload["signatures"]; + payload.remove("unsigned"); + payload.remove("signatures"); + final List canonical = canonicalJson.encode(payload); + final String signature = _olmAccount.sign(String.fromCharCodes(canonical)); + if (signatures != null) { + payload["signatures"] = signatures; + } else { + payload["signatures"] = Map(); + } + payload["signatures"][userID] = Map(); + payload["signatures"][userID]["ed25519:$deviceID"] = signature; + if (unsigned != null) { + payload["unsigned"] = unsigned; + } + return payload; + } + + /// Checks the signature of a signed json object. + bool checkJsonSignature(String key, Map signedJson, + String userId, String deviceId) { + if (!encryptionEnabled) throw ("Encryption is disabled"); + final Map signatures = signedJson["signatures"]; + if (signatures == null || !signatures.containsKey(userId)) return false; + signedJson.remove("unsigned"); + signedJson.remove("signatures"); + if (!signatures[userId].containsKey("ed25519:$deviceId")) return false; + final String signature = signatures[userId]["ed25519:$deviceId"]; + final List canonical = canonicalJson.encode(signedJson); + final String message = String.fromCharCodes(canonical); + bool isValid = true; + try { + olm.Utility() + ..ed25519_verify(key, message, signature) + ..free(); + } catch (e) { + isValid = false; + print("[LibOlm] Signature check failed: " + e.toString()); + } + return isValid; + } + + DateTime lastTimeKeysUploaded; + + /// Generates new one time keys, signs everything and upload it to the server. + Future _uploadKeys({bool uploadDeviceKeys = false}) async { + if (!encryptionEnabled) return true; + + final int oneTimeKeysCount = _olmAccount.max_number_of_one_time_keys(); + _olmAccount.generate_one_time_keys(oneTimeKeysCount); + final Map oneTimeKeys = + json.decode(_olmAccount.one_time_keys()); + + Map signedOneTimeKeys = Map(); + + for (String key in oneTimeKeys["curve25519"].keys) { + signedOneTimeKeys["signed_curve25519:$key"] = Map(); + signedOneTimeKeys["signed_curve25519:$key"]["key"] = + oneTimeKeys["curve25519"][key]; + signedOneTimeKeys["signed_curve25519:$key"] = + signJson(signedOneTimeKeys["signed_curve25519:$key"]); + } + + Map keysContent = { + if (uploadDeviceKeys) + "device_keys": { + "user_id": userID, + "device_id": deviceID, + "algorithms": [ + "m.olm.v1.curve25519-aes-sha2", + "m.megolm.v1.aes-sha2" + ], + "keys": Map(), + }, + "one_time_keys": signedOneTimeKeys, + }; + if (uploadDeviceKeys) { + final Map keys = + json.decode(_olmAccount.identity_keys()); + for (String algorithm in keys.keys) { + keysContent["device_keys"]["keys"]["$algorithm:$deviceID"] = + keys[algorithm]; + } + keysContent["device_keys"] = + signJson(keysContent["device_keys"] as Map); + } + + final Map response = await jsonRequest( + type: HTTPType.POST, + action: "/client/r0/keys/upload", + data: keysContent, + ); + if (response["one_time_key_counts"]["signed_curve25519"] != + oneTimeKeysCount) { + return false; + } + _olmAccount.mark_keys_as_published(); + await storeAPI?.storeClient(); + lastTimeKeysUploaded = DateTime.now(); + return true; + } + + /// Try to decrypt a ToDeviceEvent encrypted with olm. + ToDeviceEvent decryptToDeviceEvent(ToDeviceEvent toDeviceEvent) { + if (toDeviceEvent.content["algorithm"] != "m.olm.v1.curve25519-aes-sha2") { + throw ("Unknown algorithm: ${toDeviceEvent.content["algorithm"]}"); + } + if (!toDeviceEvent.content["ciphertext"].containsKey(identityKey)) { + throw ("The message isn't sent for this device"); + } + String plaintext; + final String senderKey = toDeviceEvent.content["sender_key"]; + final String body = + toDeviceEvent.content["ciphertext"][identityKey]["body"]; + final int type = toDeviceEvent.content["ciphertext"][identityKey]["type"]; + if (type != 0 && type != 1) { + throw ("Unknown message type"); + } + List existingSessions = olmSessions[senderKey]; + if (existingSessions != null) { + for (olm.Session session in existingSessions) { + if ((type == 0 && session.matches_inbound(body) == 1) || type == 1) { + plaintext = session.decrypt(type, body); + } + } + } + if (plaintext == null && type != 0) { + throw ("No existing sessions found"); + } + + if (plaintext == null) { + olm.Session newSession = olm.Session(); + newSession.create_inbound_from(_olmAccount, senderKey, body); + _olmAccount.remove_one_time_keys(newSession); + storeAPI?.storeClient(); + storeOlmSession(senderKey, newSession); + plaintext = newSession.decrypt(type, body); + } + final Map plainContent = json.decode(plaintext); + if (plainContent.containsKey("sender") && + plainContent["sender"] != toDeviceEvent.sender) { + throw ("Message was decrypted but sender doesn't match"); + } + if (plainContent.containsKey("recipient") && + plainContent["recipient"] != userID) { + throw ("Message was decrypted but recipient doesn't match"); + } + if (plainContent["recipient_keys"] is Map && + plainContent["recipient_keys"]["ed25519"] is String && + plainContent["recipient_keys"]["ed25519"] != fingerprintKey) { + throw ("Message was decrypted but own fingerprint Key doesn't match"); + } + return ToDeviceEvent( + content: plainContent["content"], + type: plainContent["type"], + sender: toDeviceEvent.sender, + ); + } + + /// A map from Curve25519 identity keys to existing olm sessions. + Map> get olmSessions => _olmSessions; + Map> _olmSessions = {}; + + void storeOlmSession(String curve25519IdentityKey, olm.Session session) { + if (!_olmSessions.containsKey(curve25519IdentityKey)) { + _olmSessions[curve25519IdentityKey] = []; + } + _olmSessions[curve25519IdentityKey].add(session); + Map> pickleMap = {}; + for (var entry in olmSessions.entries) { + pickleMap[entry.key] = []; + for (olm.Session session in entry.value) { + try { + pickleMap[entry.key].add(session.pickle(userID)); + } catch (e) { + print("[LibOlm] Could not pickle olm session: " + e.toString()); + } + } + } + storeAPI?.setItem("/clients/$userID/olm-sessions", json.encode(pickleMap)); + } + + /// Sends an encrypted [message] of this [type] to these [deviceKeys]. + Future sendToDevice(List deviceKeys, String type, + Map message) async { + if (!encryptionEnabled) return; + // Don't send this message to blocked devices. + if (deviceKeys?.isEmpty ?? true) return; + deviceKeys.removeWhere((DeviceKeys deviceKeys) => + deviceKeys.blocked || deviceKeys.deviceId == deviceID); + if (deviceKeys?.isEmpty ?? true) return; + + // Create new sessions with devices if there is no existing session yet. + List deviceKeysWithoutSession = + List.from(deviceKeys); + deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) => + olmSessions.containsKey(deviceKeys.curve25519Key)); + if (deviceKeysWithoutSession.isNotEmpty) { + await startOutgoingOlmSessions(deviceKeysWithoutSession); + } + Map encryptedMessage = { + "algorithm": "m.olm.v1.curve25519-aes-sha2", + "sender_key": identityKey, + "ciphertext": Map(), + }; + for (DeviceKeys device in deviceKeys) { + List existingSessions = olmSessions[device.curve25519Key]; + if (existingSessions == null || existingSessions.isEmpty) continue; + existingSessions.sort((a, b) => a.session_id().compareTo(b.session_id())); + + final Map payload = { + "type": type, + "content": message, + "sender": this.userID, + "sender_keys": {"ed25519": fingerprintKey}, + "recipient": device.userId, + "recipient_keys": {"ed25519": device.ed25519Key}, + }; + final olm.EncryptResult encryptResult = + existingSessions.first.encrypt(json.encode(payload)); + encryptedMessage["ciphertext"][device.curve25519Key] = { + "type": encryptResult.type, + "body": encryptResult.body, + }; + } + + // Send with send-to-device messaging + Map data = { + "messages": Map(), + }; + for (DeviceKeys device in deviceKeys) { + if (!data["messages"].containsKey(device.userId)) { + data["messages"][device.userId] = Map(); + } + data["messages"][device.userId][device.deviceId] = encryptedMessage; + } + final String messageID = "msg${DateTime.now().millisecondsSinceEpoch}"; + await jsonRequest( + type: HTTPType.PUT, + action: "/client/r0/sendToDevice/m.room.encrypted/$messageID", + data: data, + ); + } + + Future startOutgoingOlmSessions(List deviceKeys, + {bool checkSignature = true}) async { + Map> requestingKeysFrom = {}; + for (DeviceKeys device in deviceKeys) { + if (requestingKeysFrom[device.userId] == null) { + requestingKeysFrom[device.userId] = {}; + } + requestingKeysFrom[device.userId][device.deviceId] = "signed_curve25519"; + } + + final Map response = await jsonRequest( + type: HTTPType.POST, + action: "/client/r0/keys/claim", + data: {"timeout": 10000, "one_time_keys": requestingKeysFrom}, + ); + + for (var userKeysEntry in response["one_time_keys"].entries) { + final String userId = userKeysEntry.key; + for (var deviceKeysEntry in userKeysEntry.value.entries) { + final String deviceId = deviceKeysEntry.key; + final String fingerprintKey = + userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key; + final String identityKey = + userDeviceKeys[userId].deviceKeys[deviceId].curve25519Key; + for (Map deviceKey in deviceKeysEntry.value.values) { + if (checkSignature && + checkJsonSignature(fingerprintKey, deviceKey, userId, deviceId) == + false) { + continue; + } + try { + olm.Session session = olm.Session(); + session.create_outbound(_olmAccount, identityKey, deviceKey["key"]); + await storeOlmSession(identityKey, session); + } catch (e) { + print("[LibOlm] Could not create new outbound olm session: " + + e.toString()); + } + } + } + } + } } diff --git a/lib/src/room.dart b/lib/src/room.dart index 041a53ca..db65cfd3 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -22,6 +22,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/src/client.dart'; @@ -32,7 +33,9 @@ import 'package:famedlysdk/src/sync/room_update.dart'; import 'package:famedlysdk/src/utils/matrix_exception.dart'; import 'package:famedlysdk/src/utils/matrix_file.dart'; import 'package:famedlysdk/src/utils/mx_content.dart'; +import 'package:famedlysdk/src/utils/session_key.dart'; import 'package:mime_type/mime_type.dart'; +import 'package:olm/olm.dart' as olm; import './user.dart'; import 'timeline.dart'; @@ -78,6 +81,97 @@ class Room { /// Key-Value store for private account data only visible for this user. Map roomAccountData = {}; + olm.OutboundGroupSession get outboundGroupSession => _outboundGroupSession; + olm.OutboundGroupSession _outboundGroupSession; + + /// Clears the existing outboundGroupSession, tries to create a new one and + /// stores it as an ingoingGroupSession in the [sessionKeys]. Then sends the + /// new session encrypted with olm to all non-blocked devices using + /// to-device-messaging. + Future createOutboundGroupSession() async { + await clearOutboundGroupSession(); + try { + _outboundGroupSession = olm.OutboundGroupSession(); + _outboundGroupSession.create(); + } catch (e) { + _outboundGroupSession = null; + print("[LibOlm] Unable to create new outboundGroupSession: " + + e.toString()); + } + + if (_outboundGroupSession == null) return; + + await client.storeAPI?.setItem( + "/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session", + _outboundGroupSession.pickle(client.userID)); + // Add as an inboundSession to the [sessionKeys]. + Map rawSession = { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": this.id, + "session_id": _outboundGroupSession.session_id(), + "session_key": _outboundGroupSession.session_key(), + }; + setSessionKey(rawSession["session_id"], rawSession); + List deviceKeys = await getUserDeviceKeys(); + try { + // TODO: Fix type '_InternalLinkedHashMap' is not a subtype of type 'Iterable' + await client.sendToDevice(deviceKeys, "m.room_key", rawSession); + } catch (e) { + print( + "[LibOlm] Unable to send the session key to the participating devices: " + + e.toString()); + await clearOutboundGroupSession(); + } + return; + } + + /// Clears the existing outboundGroupSession. + Future clearOutboundGroupSession() async { + await client.storeAPI?.setItem( + "/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session", + null); + this._outboundGroupSession?.free(); + this._outboundGroupSession = null; + return; + } + + /// Key-Value store of session ids to the session keys. Only m.megolm.v1.aes-sha2 + /// session keys are supported. They are stored as a Map with the following keys: + /// { + /// "algorithm": "m.megolm.v1.aes-sha2", + /// "room_id": "!Cuyf34gef24t:localhost", + /// "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ", + /// "session_key": "AgAAAADxKHa9uFxcXzwYoNueL5Xqi69IkD4sni8LlfJL7qNBEY..." + /// } + Map get sessionKeys => _sessionKeys; + Map _sessionKeys = {}; + + /// Add a new session key to the [sessionKeys]. + void setSessionKey(String sessionId, Map content) { + if (sessionKeys.containsKey(sessionId)) return; + olm.InboundGroupSession inboundGroupSession; + if (content["algorithm"] == "m.megolm.v1.aes-sha2") { + try { + inboundGroupSession = olm.InboundGroupSession(); + inboundGroupSession.create(content["session_key"]); + } catch (e) { + inboundGroupSession = null; + print("[LibOlm] Could not create new InboundGroupSession: " + + e.toString()); + } + } + _sessionKeys[sessionId] = SessionKey( + content: content, + inboundGroupSession: inboundGroupSession, + indexes: {}, + key: client.userID, + ); + + client.storeAPI?.setItem( + "/clients/${client.deviceID}/rooms/${this.id}/session_keys", + json.encode(sessionKeys)); + } + /// Returns the [Event] for the given [typeKey] and optional [stateKey]. /// If no [stateKey] is provided, it defaults to an empty string. Event getState(String typeKey, [String stateKey = ""]) => @@ -86,6 +180,16 @@ class Room { /// Adds the [state] to this room and overwrites a state with the same /// typeKey/stateKey key pair if there is one. void setState(Event state) { + // Check if this is a member change and we need to clear the outboundGroupSession. + if (encrypted && + outboundGroupSession != null && + state.type == EventTypes.RoomMember) { + User newUser = state.asUser; + User oldUser = getState("m.room.member", newUser.id)?.asUser; + if (oldUser == null || oldUser.membership != newUser.membership) { + clearOutboundGroupSession(); + } + } if (!states.states.containsKey(state.typeKey)) { states.states[state.typeKey] = {}; } @@ -280,16 +384,6 @@ class Room { return resp["event_id"]; } - Future _sendRawEventNow(Map content, - {String txid}) async { - if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}"; - final Map res = await client.jsonRequest( - type: HTTPType.PUT, - action: "/client/r0/rooms/${id}/send/m.room.message/$txid", - data: content); - return res["event_id"]; - } - Future sendTextEvent(String message, {String txid, Event inReplyTo}) => sendEvent({"msgtype": "m.text", "body": message}, @@ -404,7 +498,7 @@ class Room { Future sendEvent(Map content, {String txid, Event inReplyTo}) async { - final String type = "m.room.message"; + final String type = this.encrypted ? "m.room.encrypted" : "m.room.message"; // Create new transaction id String messageID; @@ -423,7 +517,8 @@ class Room { } replyText = replyTextLines.join("\n"); content["format"] = "org.matrix.custom.html"; - content["formatted_body"] = '
In reply to ${inReplyTo.senderId}
${inReplyTo.body}
${content["formatted_body"] ?? content["body"]}'; + content["formatted_body"] = + '
In reply to ${inReplyTo.senderId}
${inReplyTo.body}
${content["formatted_body"] ?? content["body"]}'; content["body"] = replyText + "\n\n${content["body"] ?? ""}"; content["m.relates_to"] = { "m.in_reply_to": { @@ -450,7 +545,11 @@ class Room { // Send the text and on success, store and display a *sent* event. try { - final String res = await _sendRawEventNow(content, txid: messageID); + final Map response = await client.jsonRequest( + type: HTTPType.PUT, + action: "/client/r0/rooms/${id}/send/$type/$messageID", + data: await encryptGroupMessagePayload(content)); + final String res = response["event_id"]; eventUpdate.content["status"] = 1; eventUpdate.content["unsigned"] = {"transaction_id": messageID}; eventUpdate.content["event_id"] = res; @@ -461,6 +560,7 @@ class Room { }); return res; } catch (exception) { + print("[Client] Error while sending: " + exception.toString()); // On error, set status to -1 eventUpdate.content["status"] = -1; eventUpdate.content["unsigned"] = {"transaction_id": messageID}; @@ -704,6 +804,41 @@ class Room { return; } + void restoreGroupSessionKeys() async { + // Restore the inbound and outbound session keys + if (client.encryptionEnabled && client.storeAPI != null) { + final String outboundGroupSessionPickle = await client.storeAPI.getItem( + "/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session"); + if (outboundGroupSessionPickle != null) { + try { + this._outboundGroupSession = olm.OutboundGroupSession(); + this + ._outboundGroupSession + .unpickle(client.userID, outboundGroupSessionPickle); + } catch (e) { + this._outboundGroupSession = null; + print("[LibOlm] Unable to unpickle outboundGroupSession: " + + e.toString()); + } + } + final String sessionKeysPickle = await client.storeAPI + .getItem("/clients/${client.deviceID}/rooms/${this.id}/session_keys"); + if (sessionKeysPickle?.isNotEmpty ?? false) { + final Map map = json.decode(sessionKeysPickle); + this._sessionKeys = {}; + for (var entry in map.entries) { + try { + this._sessionKeys[entry.key] = + SessionKey.fromJson(entry.value, client.userID); + } catch (e) { + print("[LibOlm] Could not unpickle inboundGroupSession: " + + e.toString()); + } + } + } + } + } + /// Returns a Room from a json String which comes normally from the store. If the /// state are also given, the method will await them. static Future getRoomFromTableRow( @@ -725,6 +860,9 @@ class Room { roomAccountData: {}, ); + // Restore the inbound and outbound session keys + await newRoom.restoreGroupSessionKeys(); + if (states != null) { List> rawStates = await states; for (int i = 0; i < rawStates.length; i++) { @@ -753,6 +891,15 @@ class Room { onTimelineInsertCallback onInsert}) async { List events = client.store != null ? await client.store.getEventList(this) : []; + if (this.encrypted) { + for (int i = 0; i < events.length; i++) { + try { + events[i] = decryptGroupMessage(events[i]); + } catch (e) { + print("[LibOlm] Could not decrypt group message: " + e.toString()); + } + } + } Timeline timeline = Timeline( room: this, events: events, @@ -1294,6 +1441,7 @@ class Room { return; } + /// Returns all known device keys for all participants in this room. Future> getUserDeviceKeys() async { List deviceKeys = []; List users = await requestParticipants(); @@ -1308,4 +1456,73 @@ class Room { } return deviceKeys; } + + /// Encrypts the given json payload and creates a send-ready m.room.encrypted + /// payload. This will create a new outgoingGroupSession if necessary. + Future> encryptGroupMessagePayload( + Map payload, + {String type = "m.room.message"}) async { + if (!this.encrypted) return payload; + if (!client.encryptionEnabled) throw ("Encryption is not enabled"); + if (this.encryptionAlgorithm != "m.megolm.v1.aes-sha2") { + throw ("Unknown encryption algorithm"); + } + if (_outboundGroupSession == null) { + await createOutboundGroupSession(); + } + final Map payloadContent = { + "content": payload, + "type": type, + "room_id": id, + }; + Map encryptedPayload = { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": _outboundGroupSession.encrypt(json.encode(payloadContent)), + "device_id": client.deviceID, + "sender_key": client.identityKey, + "session_id": _outboundGroupSession.session_id(), + }; + return encryptedPayload; + } + + /// Decrypts the given [event] with one of the available ingoingGroupSessions. + Event decryptGroupMessage(Event event) { + if (!client.encryptionEnabled) throw ("Encryption is not enabled"); + if (event.content["algorithm"] != "m.megolm.v1.aes-sha2") { + throw ("Unknown encryption algorithm"); + } + final String sessionId = event.content["session_id"]; + if (!sessionKeys.containsKey(sessionId)) { + throw ("Unknown session id"); + } + final olm.DecryptResult decryptResult = sessionKeys[sessionId] + .inboundGroupSession + .decrypt(event.content["ciphertext"]); + final String messageIndexKey = + event.eventId + event.time.millisecondsSinceEpoch.toString(); + if (sessionKeys[sessionId].indexes.containsKey(messageIndexKey) && + sessionKeys[sessionId].indexes[messageIndexKey] != + decryptResult.message_index) { + throw ("Invalid message index"); + } + sessionKeys[sessionId].indexes[messageIndexKey] = + decryptResult.message_index; + // TODO: The client should check that the sender's fingerprint key matches the keys.ed25519 property of the event which established the Megolm session when marking the event as verified. + + final Map decryptedPayload = + json.decode(decryptResult.plaintext); + return Event( + content: decryptedPayload["content"], + typeKey: decryptedPayload["type"], + senderId: event.senderId, + eventId: event.eventId, + roomId: event.roomId, + room: event.room, + time: event.time, + unsigned: event.unsigned, + stateKey: event.stateKey, + prevContent: event.prevContent, + status: event.status, + ); + } } diff --git a/lib/src/store_api.dart b/lib/src/store_api.dart index 7a28e87c..29c823bd 100644 --- a/lib/src/store_api.dart +++ b/lib/src/store_api.dart @@ -48,9 +48,13 @@ abstract class StoreAPI { /// Clears all tables from the database. Future clear(); - Future storeUserDeviceKeys(Map userDeviceKeys); + Future getItem(String key); + + Future setItem(String key, String value); Future> getUserDeviceKeys(); + + Future storeUserDeviceKeys(Map userDeviceKeys); } /// Responsible to store all data persistent and to query objects from the diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index 18877fab..f5aca8ed 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -91,6 +91,19 @@ class Timeline { void _handleEventUpdate(EventUpdate eventUpdate) async { try { if (eventUpdate.roomID != room.id) return; + + if (eventUpdate.eventType == "m.room.encrypted") { + Event decrypted = + room.decryptGroupMessage(Event.fromJson(eventUpdate.content, room)); + eventUpdate = EventUpdate( + eventType: decrypted.typeKey, + content: eventUpdate.content, + type: eventUpdate.type, + roomID: eventUpdate.roomID, + ); + eventUpdate.content["content"] = decrypted.content; + } + if (eventUpdate.type == "timeline" || eventUpdate.type == "history") { // Redaction events are handled as modification for existing events. if (eventUpdate.eventType == "m.room.redaction") { diff --git a/lib/src/utils/device_keys_list.dart b/lib/src/utils/device_keys_list.dart index a9638434..f3500322 100644 --- a/lib/src/utils/device_keys_list.dart +++ b/lib/src/utils/device_keys_list.dart @@ -45,6 +45,9 @@ class DeviceKeys { bool verified; bool blocked; + String get curve25519Key => keys["curve25519:$deviceId"]; + String get ed25519Key => keys["ed25519:$deviceId"]; + Future setVerified(bool newVerified, Client client) { verified = newVerified; return client.storeAPI.storeUserDeviceKeys(client.userDeviceKeys); diff --git a/lib/src/utils/session_key.dart b/lib/src/utils/session_key.dart new file mode 100644 index 00000000..7e8a4cda --- /dev/null +++ b/lib/src/utils/session_key.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +import 'package:olm/olm.dart'; + +class SessionKey { + Map content; + Map indexes; + InboundGroupSession inboundGroupSession; + final String key; + + SessionKey({this.content, this.inboundGroupSession, this.key, this.indexes}); + + SessionKey.fromJson(Map json, String key) : this.key = key { + content = json['content'] != null + ? Map.from(json['content']) + : null; + indexes = json['indexes'] != null + ? Map.from(json['indexes']) + : Map(); + InboundGroupSession newInboundGroupSession = InboundGroupSession(); + newInboundGroupSession.unpickle(key, json['inboundGroupSession']); + inboundGroupSession = newInboundGroupSession; + } + + Map toJson() { + final Map data = Map(); + if (this.content != null) { + data['content'] = this.content; + } + if (this.indexes != null) { + data['indexes'] = this.indexes; + } + data['inboundGroupSession'] = this.inboundGroupSession.pickle(this.key); + return data; + } + + String toString() => json.encode(this.toJson()); +} diff --git a/lib/src/utils/to_device_event.dart b/lib/src/utils/to_device_event.dart new file mode 100644 index 00000000..6f88fbc9 --- /dev/null +++ b/lib/src/utils/to_device_event.dart @@ -0,0 +1,25 @@ +class ToDeviceEvent { + String sender; + String type; + Map content; + + ToDeviceEvent({this.sender, this.type, this.content}); + + ToDeviceEvent.fromJson(Map json) { + sender = json['sender']; + type = json['type']; + content = json['content'] != null + ? Map.from(json['content']) + : null; + } + + Map toJson() { + final Map data = Map(); + data['sender'] = this.sender; + data['type'] = this.type; + if (this.content != null) { + data['content'] = this.content; + } + return data; + } +} diff --git a/prepare.sh b/prepare.sh new file mode 100644 index 00000000..e480bfbc --- /dev/null +++ b/prepare.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +mkdir js +cd js +curl -O 'https://packages.matrix.org/npm/olm/olm-3.1.4.tgz' +tar xaf olm-3.1.4.tgz +cd .. + +if [ -f /usr/lib/x86_64-linux-gnu/libolm.so.3 ] +then +mkdir -p ffi/olm/ +ln -sf /usr/lib/x86_64-linux-gnu/libolm.so.3 ffi/olm/libolm.so +else +cd ffi +pushd ffi +git clone --depth 1 https://gitlab.matrix.org/matrix-org/olm.git +cd olm +cmake -DCMAKE_BUILD_TYPE=Release . +cmake --build . +cd .. +fi + +pub get diff --git a/pubspec.lock b/pubspec.lock index 0f863964..d1d207a9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -148,6 +148,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.7" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" fixnum: dependency: transitive description: @@ -245,7 +252,7 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.5" + version: "0.12.6" meta: dependency: transitive description: @@ -281,6 +288,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.4.8" + olm: + dependency: "direct main" + description: + path: "." + ref: "09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b" + resolved-ref: "09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b" + url: "https://gitlab.com/famedly/libraries/dart-olm.git" + source: git + version: "0.0.0" package_config: dependency: transitive description: @@ -427,21 +443,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.9.1" + version: "1.11.1" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.8" + version: "0.2.13" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.2.12" + version: "0.2.18" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f271ec7e..ef682c55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,12 @@ dependencies: mime_type: ^0.2.4 canonical_json: ^1.0.0 + olm: + git: + url: https://gitlab.com/famedly/libraries/dart-olm.git + ref: 09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b + dev_dependencies: test: ^1.0.0 build_runner: ^1.5.2 - pedantic: ^1.5.0 # DO NOT UPDATE AS THIS WOULD CAUSE FLUTTER TO FAIL \ No newline at end of file + pedantic: ^1.5.0 # DO NOT UPDATE AS THIS WOULD CAUSE FLUTTER TO FAIL diff --git a/test.sh b/test.sh new file mode 100644 index 00000000..1162dab9 --- /dev/null +++ b/test.sh @@ -0,0 +1,2 @@ +#!/bin/sh -e +pub run test -p vm diff --git a/test/client_test.dart b/test/client_test.dart index d0dafd5f..3638db4e 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -22,6 +22,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/src/account_data.dart'; @@ -35,9 +36,11 @@ import 'package:famedlysdk/src/sync/user_update.dart'; import 'package:famedlysdk/src/utils/matrix_exception.dart'; import 'package:famedlysdk/src/utils/matrix_file.dart'; import 'package:famedlysdk/src/utils/profile.dart'; +import 'package:olm/olm.dart' as olm; import 'package:test/test.dart'; import 'fake_matrix_api.dart'; +import 'fake_store.dart'; void main() { Client matrix; @@ -45,6 +48,12 @@ void main() { Future> roomUpdateListFuture; Future> eventUpdateListFuture; Future> userUpdateListFuture; + Future> toDeviceUpdateListFuture; + + const String pickledOlmAccount = + "N2v1MkIFGcl0mQpo2OCwSopxPQJ0wnl7oe7PKiT4141AijfdTIhRu+ceXzXKy3Kr00nLqXtRv7kid6hU4a+V0rfJWLL0Y51+3Rp/ORDVnQy+SSeo6Fn4FHcXrxifJEJ0djla5u98fBcJ8BSkhIDmtXRPi5/oJAvpiYn+8zMjFHobOeZUAxYR0VfQ9JzSYBsSovoQ7uFkNks1M4EDUvHtuweStA+EKZvvHZO0SnwRp0Hw7sv8UMYvXw"; + const String identityKey = "7rvl3jORJkBiK4XX1e5TnGnqz068XfYJ0W++Ml63rgk"; + const String fingerprintKey = "gjL//fyaFHADt9KBADGag8g7F8Up78B/K1zXeiEPLJo"; /// All Tests related to the Login group("FluffyMatrix", () { @@ -56,6 +65,16 @@ void main() { roomUpdateListFuture = matrix.onRoomUpdate.stream.toList(); eventUpdateListFuture = matrix.onEvent.stream.toList(); userUpdateListFuture = matrix.onUserEvent.stream.toList(); + toDeviceUpdateListFuture = matrix.onToDeviceEvent.stream.toList(); + bool olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + print("[LibOlm] Failed to load LibOlm: " + _.toString()); + } + print("[LibOlm] Enabled: $olmEnabled"); test('Login', () async { int presenceCounter = 0; @@ -106,13 +125,16 @@ void main() { Future syncFuture = matrix.onSync.stream.first; matrix.connect( - newToken: resp["access_token"], - newUserID: resp["user_id"], - newHomeserver: matrix.homeserver, - newDeviceName: "Text Matrix Client", - newDeviceID: resp["device_id"], - newMatrixVersions: matrix.matrixVersions, - newLazyLoadMembers: matrix.lazyLoadMembers); + newToken: resp["access_token"], + newUserID: resp["user_id"], + newHomeserver: matrix.homeserver, + newDeviceName: "Text Matrix Client", + newDeviceID: resp["device_id"], + newMatrixVersions: matrix.matrixVersions, + newLazyLoadMembers: matrix.lazyLoadMembers, + newOlmAccount: pickledOlmAccount, + ); + await Future.delayed(Duration(milliseconds: 50)); expect(matrix.accessToken == resp["access_token"], true); @@ -126,6 +148,12 @@ void main() { expect(loginState, LoginState.logged); expect(firstSync, true); + expect(matrix.encryptionEnabled, olmEnabled); + if (olmEnabled) { + expect(matrix.pickledOlmAccount, pickledOlmAccount); + expect(matrix.identityKey, identityKey); + expect(matrix.fingerprintKey, fingerprintKey); + } expect(sync["next_batch"] == matrix.prevBatch, true); expect(matrix.accountData.length, 3); @@ -135,6 +163,22 @@ void main() { expect(matrix.directChats, matrix.accountData["m.direct"].content); expect(matrix.presences.length, 1); expect(matrix.rooms[1].ephemerals.length, 2); + expect(matrix.rooms[1].sessionKeys.length, 1); + expect( + matrix + .rooms[1] + .sessionKeys["ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU"] + .content["session_key"], + "AgAAAAAQcQ6XrFJk6Prm8FikZDqfry/NbDz8Xw7T6e+/9Yf/q3YHIPEQlzv7IZMNcYb51ifkRzFejVvtphS7wwG2FaXIp4XS2obla14iKISR0X74ugB2vyb1AydIHE/zbBQ1ic5s3kgjMFlWpu/S3FQCnCrv+DPFGEt3ERGWxIl3Bl5X53IjPyVkz65oljz2TZESwz0GH/QFvyOOm8ci0q/gceaF3S7Dmafg3dwTKYwcA5xkcc+BLyrLRzB6Hn+oMAqSNSscnm4mTeT5zYibIhrzqyUTMWr32spFtI9dNR/RFSzfCw"); + if (olmEnabled) { + expect( + matrix + .rooms[1] + .sessionKeys["ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU"] + .inboundGroupSession != + null, + true); + } expect(matrix.rooms[1].typingUsers.length, 1); expect(matrix.rooms[1].typingUsers[0].id, "@alice:example.com"); expect(matrix.rooms[1].roomAccountData.length, 3); @@ -330,7 +374,7 @@ void main() { List eventUpdateList = await userUpdateListFuture; - expect(eventUpdateList.length, 5); + expect(eventUpdateList.length, 4); expect(eventUpdateList[0].eventType, "m.presence"); expect(eventUpdateList[0].type, "presence"); @@ -342,6 +386,17 @@ void main() { expect(eventUpdateList[2].type, "account_data"); }); + test('To Device Update Test', () async { + await matrix.onToDeviceEvent.close(); + + List eventUpdateList = await toDeviceUpdateListFuture; + + expect(eventUpdateList.length, 2); + + expect(eventUpdateList[0].type, "m.new_device"); + expect(eventUpdateList[1].type, "m.room_key"); + }); + test('Login', () async { matrix = Client("testclient", debug: true); matrix.httpClient = FakeMatrixApi(); @@ -417,6 +472,147 @@ void main() { expect(profile.content["displayname"], profile.displayname); }); + test('signJson', () { + if (matrix.encryptionEnabled) { + expect(matrix.fingerprintKey.isNotEmpty, true); + expect(matrix.identityKey.isNotEmpty, true); + Map payload = { + "unsigned": { + "foo": "bar", + }, + "auth": { + "success": true, + "mxid": "@john.doe:example.com", + "profile": { + "display_name": "John Doe", + "three_pids": [ + {"medium": "email", "address": "john.doe@example.org"}, + {"medium": "msisdn", "address": "123456789"} + ] + } + } + }; + Map payloadWithoutUnsigned = Map.from(payload); + payloadWithoutUnsigned.remove("unsigned"); + + expect( + matrix.checkJsonSignature( + matrix.fingerprintKey, payload, matrix.userID, matrix.deviceID), + false); + expect( + matrix.checkJsonSignature(matrix.fingerprintKey, + payloadWithoutUnsigned, matrix.userID, matrix.deviceID), + false); + payload = matrix.signJson(payload); + payloadWithoutUnsigned = matrix.signJson(payloadWithoutUnsigned); + expect(payload["signatures"], payloadWithoutUnsigned["signatures"]); + print(payload["signatures"]); + expect( + matrix.checkJsonSignature( + matrix.fingerprintKey, payload, matrix.userID, matrix.deviceID), + true); + expect( + matrix.checkJsonSignature(matrix.fingerprintKey, + payloadWithoutUnsigned, matrix.userID, matrix.deviceID), + true); + } + }); + test('Track oneTimeKeys', () async { + if (matrix.encryptionEnabled) { + DateTime last = matrix.lastTimeKeysUploaded ?? DateTime.now(); + matrix.handleSync({ + "device_one_time_keys_count": {"signed_curve25519": 49} + }); + await Future.delayed(Duration(milliseconds: 50)); + expect( + matrix.lastTimeKeysUploaded.millisecondsSinceEpoch > + last.millisecondsSinceEpoch, + true); + } + }); + test('Test invalidate outboundGroupSessions', () async { + if (matrix.encryptionEnabled) { + expect(matrix.rooms[1].outboundGroupSession == null, true); + await matrix.rooms[1].createOutboundGroupSession(); + expect(matrix.rooms[1].outboundGroupSession != null, true); + matrix.handleSync({ + "device_lists": { + "changed": [ + "@alice:example.com", + ], + "left": [ + "@bob:example.com", + ], + } + }); + await Future.delayed(Duration(milliseconds: 50)); + expect(matrix.rooms[1].outboundGroupSession == null, true); + } + }); + test('Test invalidate outboundGroupSessions', () async { + if (matrix.encryptionEnabled) { + expect(matrix.rooms[1].outboundGroupSession == null, true); + await matrix.rooms[1].createOutboundGroupSession(); + expect(matrix.rooms[1].outboundGroupSession != null, true); + matrix.handleSync({ + "rooms": { + "join": { + "!726s6s6q:example.com": { + "state": { + "events": [ + { + "content": {"membership": "leave"}, + "event_id": "143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!726s6s6q:example.com", + "sender": "@alice:example.com", + "state_key": "@alice:example.com", + "type": "m.room.member" + } + ] + } + } + } + } + }); + await Future.delayed(Duration(milliseconds: 50)); + expect(matrix.rooms[1].outboundGroupSession == null, true); + } + }); + DeviceKeys deviceKeys = DeviceKeys.fromJson({ + "user_id": "@alice:example.com", + "device_id": "JLAFKJWSCS", + "algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + "keys": { + "curve25519:JLAFKJWSCS": "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI", + "ed25519:JLAFKJWSCS": "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI" + }, + "signatures": { + "@alice:example.com": { + "ed25519:JLAFKJWSCS": + "dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA" + } + } + }); + test('startOutgoingOlmSessions', () async { + expect(matrix.olmSessions.length, 0); + if (olmEnabled) { + await matrix + .startOutgoingOlmSessions([deviceKeys], checkSignature: false); + expect(matrix.olmSessions.length, 1); + expect(matrix.olmSessions.entries.first.key, + "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI"); + } + }); + test('sendToDevice', () async { + await matrix.sendToDevice( + [deviceKeys], + "m.message", + { + "msgtype": "m.text", + "body": "Hello world", + }); + }); test('Logout when token is unknown', () async { Future loginStateFuture = matrix.onLoginStateChanged.stream.first; @@ -432,5 +628,63 @@ void main() { expect(state, LoginState.loggedOut); expect(matrix.isLogged(), false); }); + test('Test the fake store api', () async { + Client client1 = Client("testclient", debug: true); + client1.httpClient = FakeMatrixApi(); + FakeStore fakeStore = FakeStore(client1, {}); + client1.storeAPI = fakeStore; + + client1.connect( + newToken: "abc123", + newUserID: "@test:fakeServer.notExisting", + newHomeserver: "https://fakeServer.notExisting", + newDeviceName: "Text Matrix Client", + newDeviceID: "GHTYAJCE", + newMatrixVersions: [ + "r0.0.1", + "r0.1.0", + "r0.2.0", + "r0.3.0", + "r0.4.0", + "r0.5.0" + ], + newLazyLoadMembers: true, + newOlmAccount: pickledOlmAccount, + ); + + await Future.delayed(Duration(milliseconds: 50)); + + String sessionKey; + if (client1.encryptionEnabled) { + await client1.rooms[1].createOutboundGroupSession(); + + sessionKey = client1.rooms[1].outboundGroupSession.session_key(); + } + + expect(client1.isLogged(), true); + expect(client1.rooms.length, 2); + + Client client2 = Client("testclient", debug: true); + client2.httpClient = FakeMatrixApi(); + client2.storeAPI = FakeStore(client2, fakeStore.storeMap); + + await Future.delayed(Duration(milliseconds: 100)); + + expect(client2.isLogged(), true); + expect(client2.accessToken, client1.accessToken); + expect(client2.userID, client1.userID); + expect(client2.homeserver, client1.homeserver); + expect(client2.deviceID, client1.deviceID); + expect(client2.deviceName, client1.deviceName); + expect(client2.matrixVersions, client1.matrixVersions); + expect(client2.lazyLoadMembers, client1.lazyLoadMembers); + if (client2.encryptionEnabled) { + expect(client2.pickledOlmAccount, client1.pickledOlmAccount); + expect(json.encode(client2.rooms[1].sessionKeys[sessionKey]), + json.encode(client1.rooms[1].sessionKeys[sessionKey])); + expect(client2.rooms[1].id, client1.rooms[1].id); + expect(client2.rooms[1].outboundGroupSession.session_key(), sessionKey); + } + }); }); } diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index f6af1724..2f68a09a 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -57,6 +57,9 @@ class FakeMatrixApi extends MockClient { if (res.containsKey("errcode")) { return Response(json.encode(res), 405); } + } else if (method == "PUT" && + action.contains("/client/r0/sendToDevice/m.room.encrypted/")) { + return Response(json.encode({}), 200); } else if (method == "GET" && action.contains("/client/r0/rooms/") && action.contains("/state/m.room.member/")) { @@ -335,7 +338,18 @@ class FakeMatrixApi extends MockClient { "device_id": "XYZABCDE", "rooms": ["!726s6s6q:example.com"] } - } + }, + { + "sender": "@alice:example.com", + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": "!726s6s6q:example.com", + "session_id": "ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU", + "session_key": + "AgAAAAAQcQ6XrFJk6Prm8FikZDqfry/NbDz8Xw7T6e+/9Yf/q3YHIPEQlzv7IZMNcYb51ifkRzFejVvtphS7wwG2FaXIp4XS2obla14iKISR0X74ugB2vyb1AydIHE/zbBQ1ic5s3kgjMFlWpu/S3FQCnCrv+DPFGEt3ERGWxIl3Bl5X53IjPyVkz65oljz2TZESwz0GH/QFvyOOm8ci0q/gceaF3S7Dmafg3dwTKYwcA5xkcc+BLyrLRzB6Hn+oMAqSNSscnm4mTeT5zYibIhrzqyUTMWr32spFtI9dNR/RFSzfCw" + }, + "type": "m.room_key" + }, ] }, "rooms": { @@ -768,6 +782,30 @@ class FakeMatrixApi extends MockClient { {"available": true}, }, "POST": { + "/client/r0/keys/claim": (var req) => { + "failures": {}, + "one_time_keys": { + "@alice:example.com": { + "JLAFKJWSCS": { + "signed_curve25519:AAAAHg": { + "key": "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + "signatures": { + "@alice:example.com": { + "ed25519:JLAFKJWSCS": + "FLWxXqGbwrb8SM3Y795eB6OA8bwBcoMZFXBqnTn58AYWZSqiD45tlBVcDa2L7RwdKXebW/VzDlnfVJ+9jok1Bw" + } + } + } + } + } + } + }, + "/client/r0/keys/upload": (var req) => { + "one_time_key_counts": { + "curve25519": 10, + "signed_curve25519": 100, + } + }, "/client/r0/keys/query": (var req) => { "failures": {}, "device_keys": { diff --git a/test/fake_store.dart b/test/fake_store.dart new file mode 100644 index 00000000..0d975166 --- /dev/null +++ b/test/fake_store.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:famedlysdk/famedlysdk.dart'; + +class FakeStore implements StoreAPI { + /// Whether this is a simple store which only stores the client credentials and + /// end to end encryption stuff or the whole sync payloads. + final bool extended = false; + + Map storeMap = {}; + + /// Link back to the client. + Client client; + + FakeStore(this.client, this.storeMap) { + _init(); + } + + _init() async { + final credentialsStr = await getItem(client.clientName); + + if (credentialsStr == null || credentialsStr.isEmpty) { + client.onLoginStateChanged.add(LoginState.loggedOut); + return; + } + print("[Matrix] Restoring account credentials"); + final Map credentials = json.decode(credentialsStr); + client.connect( + newDeviceID: credentials["deviceID"], + newDeviceName: credentials["deviceName"], + newHomeserver: credentials["homeserver"], + newLazyLoadMembers: credentials["lazyLoadMembers"], + newMatrixVersions: List.from(credentials["matrixVersions"]), + newToken: credentials["token"], + newUserID: credentials["userID"], + newPrevBatch: credentials["prev_batch"], + newOlmAccount: credentials["olmAccount"], + ); + } + + /// Will be automatically called when the client is logged in successfully. + Future storeClient() async { + final Map credentials = { + "deviceID": client.deviceID, + "deviceName": client.deviceName, + "homeserver": client.homeserver, + "lazyLoadMembers": client.lazyLoadMembers, + "matrixVersions": client.matrixVersions, + "token": client.accessToken, + "userID": client.userID, + "olmAccount": client.pickledOlmAccount, + }; + await setItem(client.clientName, json.encode(credentials)); + return; + } + + /// Clears all tables from the database. + Future clear() async { + storeMap = {}; + return; + } + + Future getItem(String key) async { + return storeMap[key]; + } + + Future setItem(String key, String value) async { + storeMap[key] = value; + return; + } + + String get _UserDeviceKeysKey => "${client.clientName}.user_device_keys"; + + Future> getUserDeviceKeys() async { + final deviceKeysListString = await getItem(_UserDeviceKeysKey); + if (deviceKeysListString == null) return {}; + Map rawUserDeviceKeys = json.decode(deviceKeysListString); + Map userDeviceKeys = {}; + for (final entry in rawUserDeviceKeys.entries) { + userDeviceKeys[entry.key] = DeviceKeysList.fromJson(entry.value); + } + return userDeviceKeys; + } + + Future storeUserDeviceKeys( + Map userDeviceKeys) async { + await setItem(_UserDeviceKeysKey, json.encode(userDeviceKeys)); + } +} diff --git a/test/room_test.dart b/test/room_test.dart index 8a62d212..cd9d7b92 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -362,5 +362,79 @@ void main() { .add(matrix.accountData["m.push_rules"].content["global"]["room"][0]); expect(room.pushRuleState, PushRuleState.dont_notify); }); + + test('Enable encryption', () async { + room.setState( + Event( + senderId: "@alice:test.abc", + typeKey: "m.room.encryption", + roomId: room.id, + room: room, + eventId: "12345", + time: DateTime.now(), + content: { + "algorithm": "m.megolm.v1.aes-sha2", + "rotation_period_ms": 604800000, + "rotation_period_msgs": 100 + }, + stateKey: ""), + ); + expect(room.encrypted, true); + expect(room.encryptionAlgorithm, "m.megolm.v1.aes-sha2"); + expect(room.outboundGroupSession, null); + }); + + test('createOutboundGroupSession', () async { + if (!room.client.encryptionEnabled) return; + await room.createOutboundGroupSession(); + expect(room.outboundGroupSession != null, true); + expect(room.outboundGroupSession.session_id().isNotEmpty, true); + expect( + room.sessionKeys.containsKey(room.outboundGroupSession.session_id()), + true); + expect( + room.sessionKeys[room.outboundGroupSession.session_id()] + .content["session_key"], + room.outboundGroupSession.session_key()); + expect( + room.sessionKeys[room.outboundGroupSession.session_id()].indexes + .length, + 0); + }); + + test('clearOutboundGroupSession', () async { + if (!room.client.encryptionEnabled) return; + await room.clearOutboundGroupSession(); + expect(room.outboundGroupSession == null, true); + }); + + test('encryptGroupMessagePayload and decryptGroupMessage', () async { + if (!room.client.encryptionEnabled) return; + final Map payload = { + "msgtype": "m.text", + "body": "Hello world", + }; + final Map encryptedPayload = + await room.encryptGroupMessagePayload(payload); + expect(encryptedPayload["algorithm"], "m.megolm.v1.aes-sha2"); + expect(encryptedPayload["ciphertext"].isNotEmpty, true); + expect(encryptedPayload["device_id"], room.client.deviceID); + expect(encryptedPayload["sender_key"], room.client.identityKey); + expect(encryptedPayload["session_id"], + room.outboundGroupSession.session_id()); + + Event encryptedEvent = Event( + content: encryptedPayload, + typeKey: "m.room.encrypted", + senderId: room.client.userID, + eventId: "1234", + roomId: room.id, + room: room, + time: DateTime.now(), + ); + Event decryptedEvent = room.decryptGroupMessage(encryptedEvent); + expect(decryptedEvent.typeKey, "m.room.message"); + expect(decryptedEvent.content, payload); + }); }); } diff --git a/test_driver/famedlysdk_test.dart b/test_driver/famedlysdk_test.dart new file mode 100644 index 00000000..f0d104e8 --- /dev/null +++ b/test_driver/famedlysdk_test.dart @@ -0,0 +1,131 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import '../test/fake_store.dart'; + +void main() => test(); + +const String homeserver = "https://matrix.test.famedly.de"; +const String testUserA = "@tick:test.famedly.de"; +const String testPasswordA = "test"; +const String testUserB = "@trick:test.famedly.de"; +const String testPasswordB = "test"; +const String testMessage = "Hello world"; +const String testMessage2 = "Hello moon"; +const String testMessage3 = "Hello sun"; + +void test() async { + print("++++ Login $testUserA ++++"); + Client testClientA = Client("TestClient", debug: false); + testClientA.storeAPI = FakeStore(testClientA, Map()); + await testClientA.checkServer(homeserver); + await testClientA.login(testUserA, testPasswordA); + + print("++++ Login $testUserB ++++"); + Client testClientB = Client("TestClient", debug: false); + testClientB.storeAPI = FakeStore(testClientB, Map()); + await testClientB.checkServer(homeserver); + await testClientB.login(testUserB, testPasswordA); + + print("++++ ($testUserA) Leave all rooms ++++"); + while (testClientA.rooms.isNotEmpty) { + Room room = testClientA.rooms.first; + if (room.canonicalAlias?.isNotEmpty ?? false) { + break; + } + await room.leave(); + await room.forget(); + } + + print("++++ ($testUserB) Leave all rooms ++++"); + if (testClientB.rooms.isNotEmpty) { + Room room = testClientB.rooms.first; + await room.leave(); + await room.forget(); + } + if (testClientB.rooms.isNotEmpty) { + Room room = testClientB.rooms.first; + await room.leave(); + await room.forget(); + } + + print("++++ ($testUserA) Create room and invite $testUserB ++++"); + await testClientA.createRoom(invite: [User(testUserB)]); + await Future.delayed(Duration(seconds: 1)); + Room room = testClientA.rooms.first; + assert(room != null); + final String roomId = room.id; + + print("++++ ($testUserB) Join room ++++"); + Room inviteRoom = testClientB.getRoomById(roomId); + await inviteRoom.join(); + await Future.delayed(Duration(seconds: 1)); + assert(inviteRoom.membership == Membership.join); + + print("++++ ($testUserA) Enable encryption ++++"); + assert(room.encrypted == false); + await room.enableEncryption(); + await Future.delayed(Duration(seconds: 5)); + assert(room.encrypted == true); + assert(room.outboundGroupSession == null); + + print("++++ ($testUserA) Check known olm devices ++++"); + assert(testClientA.userDeviceKeys.containsKey(testUserB)); + assert(testClientA.userDeviceKeys[testUserB].deviceKeys + .containsKey(testClientB.deviceID)); + + print("++++ ($testUserA) Send encrypted message: '$testMessage' ++++"); + await room.sendTextEvent(testMessage); + await Future.delayed(Duration(seconds: 5)); + assert(room.outboundGroupSession != null); + final String currentSessionIdA = room.outboundGroupSession.session_id(); + assert(room.sessionKeys.containsKey(room.outboundGroupSession.session_id())); + assert(testClientA.olmSessions[testClientB.identityKey].length == 1); + assert(testClientB.olmSessions[testClientA.identityKey].length == 1); + assert(inviteRoom.sessionKeys + .containsKey(room.outboundGroupSession.session_id())); + assert(room.lastMessage == testMessage); + assert(inviteRoom.lastMessage == testMessage); + print( + "++++ ($testUserB) Received decrypted message: '${inviteRoom.lastMessage}' ++++"); + + print("++++ ($testUserA) Send again encrypted message: '$testMessage2' ++++"); + await room.sendTextEvent(testMessage2); + await Future.delayed(Duration(seconds: 5)); + assert(testClientA.olmSessions[testClientB.identityKey].length == 1); + assert(testClientB.olmSessions[testClientA.identityKey].length == 1); + assert(room.outboundGroupSession.session_id() == currentSessionIdA); + assert(inviteRoom.sessionKeys + .containsKey(room.outboundGroupSession.session_id())); + assert(room.lastMessage == testMessage2); + assert(inviteRoom.lastMessage == testMessage2); + print( + "++++ ($testUserB) Received decrypted message: '${inviteRoom.lastMessage}' ++++"); + + print("++++ ($testUserB) Send again encrypted message: '$testMessage3' ++++"); + await inviteRoom.sendTextEvent(testMessage3); + await Future.delayed(Duration(seconds: 5)); + assert(testClientA.olmSessions[testClientB.identityKey].length == 1); + assert(testClientB.olmSessions[testClientA.identityKey].length == 1); + assert(room.outboundGroupSession.session_id() == currentSessionIdA); + assert(inviteRoom.outboundGroupSession != null); + assert(inviteRoom.sessionKeys + .containsKey(inviteRoom.outboundGroupSession.session_id())); + assert(room.sessionKeys + .containsKey(inviteRoom.outboundGroupSession.session_id())); + assert(inviteRoom.lastMessage == testMessage3); + assert(room.lastMessage == testMessage3); + print( + "++++ ($testUserA) Received decrypted message: '${room.lastMessage}' ++++"); + + print("++++ Logout $testUserA and $testUserB ++++"); + await room.leave(); + await room.forget(); + await inviteRoom.leave(); + await inviteRoom.forget(); + await Future.delayed(Duration(seconds: 1)); + await testClientA.jsonRequest( + type: HTTPType.POST, action: "/client/r0/logout/all"); + await testClientB.jsonRequest( + type: HTTPType.POST, action: "/client/r0/logout/all"); + testClientA = null; + testClientB = null; +}