diff --git a/lib/famedlysdk.dart b/lib/famedlysdk.dart index 1987957..b2a7183 100644 --- a/lib/famedlysdk.dart +++ b/lib/famedlysdk.dart @@ -35,6 +35,7 @@ export 'package:famedlysdk/src/utils/open_id_credentials.dart'; 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/room_key_request.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'; diff --git a/lib/src/client.dart b/lib/src/client.dart index fcca284..e7ad2e1 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -33,6 +33,7 @@ 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/room_key_request.dart'; import 'package:famedlysdk/src/utils/session_key.dart'; import 'package:famedlysdk/src/utils/to_device_event.dart'; import 'package:famedlysdk/src/utils/turn_server_credentials.dart'; @@ -620,6 +621,10 @@ class Client { /// Will be called on call answers. final StreamController onCallAnswer = StreamController.broadcast(); + /// Will be called when another device is requesting session keys for a room. + final StreamController onRoomKeyRequest = + StreamController.broadcast(); + /// Matrix synchronisation is done with https long polling. This needs a /// timeout which is usually 30 seconds. int syncTimeoutSec = 30; @@ -1320,17 +1325,67 @@ class Client { try { switch (toDeviceEvent.type) { case "m.room_key": + case "m.forwarded_room_key": Room room = getRoomById(toDeviceEvent.content["room_id"]); - if (room != null && toDeviceEvent.content["session_id"] is String) { + if (room != null) { final String sessionId = toDeviceEvent.content["session_id"]; if (room != null) { - room.setSessionKey(sessionId, toDeviceEvent.content); + if (toDeviceEvent.type == "m.room_key" && + userDeviceKeys.containsKey(toDeviceEvent.sender) && + userDeviceKeys[toDeviceEvent.sender].deviceKeys.containsKey( + toDeviceEvent.content["requesting_device_id"])) { + toDeviceEvent.content["sender_claimed_ed25519_key"] = + userDeviceKeys[toDeviceEvent.sender] + .deviceKeys[ + toDeviceEvent.content["requesting_device_id"]] + .ed25519Key; + } + room.setSessionKey( + sessionId, + toDeviceEvent.content, + forwarded: toDeviceEvent.type == "m.forwarded_room_key", + ); + if (toDeviceEvent.type == "m.forwarded_room_key") { + sendToDevice( + [], + "m.room_key_request", + { + "action": "request_cancellation", + "request_id": base64.encode( + utf8.encode(toDeviceEvent.content["room_id"])), + "requesting_device_id": room.client.deviceID, + }); + } + } + } + break; + case "m.room_key_request": + if (!toDeviceEvent.content.containsKey("body")) break; + Room room = getRoomById(toDeviceEvent.content["body"]["room_id"]); + DeviceKeys deviceKeys; + final String sessionId = toDeviceEvent.content["body"]["session_id"]; + if (userDeviceKeys.containsKey(toDeviceEvent.sender) && + userDeviceKeys[toDeviceEvent.sender] + .deviceKeys + .containsKey(toDeviceEvent.content["requesting_device_id"])) { + deviceKeys = userDeviceKeys[toDeviceEvent.sender] + .deviceKeys[toDeviceEvent.content["requesting_device_id"]]; + if (room.sessionKeys.containsKey(sessionId)) { + final RoomKeyRequest roomKeyRequest = + RoomKeyRequest.fromToDeviceEvent(toDeviceEvent, this); + if (deviceKeys.userId == userID && + deviceKeys.verified && + !deviceKeys.blocked) { + roomKeyRequest.forwardKey(); + } else { + onRoomKeyRequest.add(roomKeyRequest); + } } } break; } } catch (e) { - print(e); + print("[Matrix] Error while processing to-device-event: " + e.toString()); } } @@ -1650,65 +1705,80 @@ class Client { 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; + /// Sends an encrypted [message] of this [type] to these [deviceKeys]. To send + /// the request to all devices of the current user, pass an empty list to [deviceKeys]. + Future sendToDevice( + List deviceKeys, String type, Map message, + {bool encrypted = true}) async { + if (encrypted && !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); + if (deviceKeys.isNotEmpty) { + deviceKeys.removeWhere((DeviceKeys deviceKeys) => + deviceKeys.blocked || deviceKeys.deviceId == deviceID); + if (deviceKeys.isEmpty) return; } - 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, - "keys": {"ed25519": fingerprintKey}, - "recipient": device.userId, - "recipient_keys": {"ed25519": device.ed25519Key}, - }; - final olm.EncryptResult encryptResult = - existingSessions.first.encrypt(json.encode(payload)); - storeOlmSession(device.curve25519Key, existingSessions.first); - encryptedMessage["ciphertext"][device.curve25519Key] = { - "type": encryptResult.type, - "body": encryptResult.body, + Map sendToDeviceMessage = message; + + if (encrypted) { + // 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); + } + sendToDeviceMessage = { + "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, + "keys": {"ed25519": fingerprintKey}, + "recipient": device.userId, + "recipient_keys": {"ed25519": device.ed25519Key}, + }; + final olm.EncryptResult encryptResult = + existingSessions.first.encrypt(json.encode(payload)); + storeOlmSession(device.curve25519Key, existingSessions.first); + sendToDeviceMessage["ciphertext"][device.curve25519Key] = { + "type": encryptResult.type, + "body": encryptResult.body, + }; + } + type = "m.room.encrypted"; } // 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(); + if (deviceKeys.isEmpty) { + data["messages"][this.userID] = Map(); + data["messages"][this.userID]["*"] = sendToDeviceMessage; + } else { + for (int i = 0; i < deviceKeys.length; i++) { + DeviceKeys device = deviceKeys[i]; + if (!data["messages"].containsKey(device.userId)) { + data["messages"][device.userId] = Map(); + } + data["messages"][device.userId][device.deviceId] = sendToDeviceMessage; } - 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", + action: "/client/r0/sendToDevice/$type/$messageID", data: data, ); } diff --git a/lib/src/event.dart b/lib/src/event.dart index 4d95306..1313eb8 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -388,6 +388,42 @@ class Event { /// Trys to decrypt this event. Returns a m.bad.encrypted event /// if it fails and does nothing if the event was not encrypted. Event get decrypted => room.decryptGroupMessage(this); + + /// If this event is encrypted and the decryption was not successful because + /// the session is unknown, this requests the session key from other devices + /// in the room. If the event is not encrypted or the decryption failed because + /// of a different error, this throws an exception. + Future requestKey() async { + if (this.type != EventTypes.Encrypted || + this.messageType != MessageTypes.BadEncrypted || + this.content["body"] != DecryptError.UNKNOWN_SESSION) { + throw ("Session key not unknown"); + } + await room.client.sendToDevice( + [], + "m.room_key_request", + { + "action": "request_cancellation", + "request_id": base64.encode(utf8.encode(content["session_id"])), + "requesting_device_id": room.client.deviceID, + }); + await room.client.sendToDevice( + [], + "m.room_key_request", + { + "action": "request", + "body": { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": roomId, + "sender_key": content["sender_key"], + "session_id": content["session_id"], + }, + "request_id": base64.encode(utf8.encode(content["session_id"])), + "requesting_device_id": room.client.deviceID, + }, + encrypted: false); + return; + } } enum MessageTypes { diff --git a/lib/src/room.dart b/lib/src/room.dart index f9ddddd..6bfdfba 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -152,13 +152,18 @@ class Room { Map _sessionKeys = {}; /// Add a new session key to the [sessionKeys]. - void setSessionKey(String sessionId, Map content) { + void setSessionKey(String sessionId, Map content, + {bool forwarded = false}) { 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"]); + if (forwarded) { + inboundGroupSession.import_session(content["session_key"]); + } else { + inboundGroupSession.create(content["session_key"]); + } } catch (e) { inboundGroupSession = null; print("[LibOlm] Could not create new InboundGroupSession: " + @@ -176,6 +181,7 @@ class Room { "/clients/${client.deviceID}/rooms/${this.id}/session_keys", json.encode(sessionKeys)); } + onSessionKeyReceived.add(sessionId); } /// Returns the [Event] for the given [typeKey] and optional [stateKey]. @@ -219,6 +225,11 @@ class Room { /// room id. final StreamController onUpdate = StreamController.broadcast(); + /// If there is a new session key received, this will be triggered with + /// the session ID. + final StreamController onSessionKeyReceived = + StreamController.broadcast(); + /// The name of the room if set by a participant. String get name => states["m.room.name"] != null ? states["m.room.name"].content["name"] diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index 0e18c0c..14ab727 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -42,6 +42,7 @@ class Timeline { final onTimelineInsertCallback onInsert; StreamSubscription sub; + StreamSubscription sessionIdReceivedSub; bool _requestingHistoryLock = false; Map _eventCache = {}; @@ -77,6 +78,30 @@ class Timeline { Timeline({this.room, this.events, this.onUpdate, this.onInsert}) { sub ??= room.client.onEvent.stream.listen(_handleEventUpdate); + sessionIdReceivedSub ??= + room.onSessionKeyReceived.stream.listen(_sessionKeyReceived); + } + + /// Don't forget to call this before you dismiss this object! + void cancelSubscriptions() { + sub?.cancel(); + sessionIdReceivedSub?.cancel(); + } + + void _sessionKeyReceived(String sessionId) { + bool decryptAtLeastOneEvent = false; + for (int i = 0; i < events.length; i++) { + if (events[i].type == EventTypes.Encrypted && + events[i].messageType == MessageTypes.BadEncrypted && + events[i].content["body"] == DecryptError.UNKNOWN_SESSION && + events[i].content["session_id"] == sessionId) { + events[i] = events[i].decrypted; + if (events[i].type != EventTypes.Encrypted) { + decryptAtLeastOneEvent = true; + } + } + } + if (decryptAtLeastOneEvent) onUpdate(); } int _findEvent({String event_id, String unsigned_txid}) { diff --git a/lib/src/utils/room_key_request.dart b/lib/src/utils/room_key_request.dart new file mode 100644 index 0000000..9277ceb --- /dev/null +++ b/lib/src/utils/room_key_request.dart @@ -0,0 +1,36 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/src/utils/session_key.dart'; + +class RoomKeyRequest extends ToDeviceEvent { + Client client; + RoomKeyRequest.fromToDeviceEvent(ToDeviceEvent toDeviceEvent, Client client) { + this.client = client; + this.sender = toDeviceEvent.sender; + this.content = toDeviceEvent.content; + this.type = toDeviceEvent.type; + } + + Room get room => client.getRoomById(this.content["body"]["room_id"]); + + DeviceKeys get requestingDevice => + client.userDeviceKeys[sender].deviceKeys[content["requesting_device_id"]]; + + Future forwardKey() async { + Room room = this.room; + final SessionKey session = + room.sessionKeys[this.content["body"]["session_id"]]; + List forwardedKeys = [client.identityKey]; + for (final key in session.forwardingCurve25519KeyChain) { + forwardedKeys.add(key); + } + await requestingDevice.setVerified(true, client); + Map message = session.content; + message["forwarding_curve25519_key_chain"] = forwardedKeys; + message["session_key"] = session.inboundGroupSession.export_session(0); + await client.sendToDevice( + [requestingDevice], + "m.forwarded_room_key", + message, + ); + } +} diff --git a/lib/src/utils/session_key.dart b/lib/src/utils/session_key.dart index 7e8a4cd..13add08 100644 --- a/lib/src/utils/session_key.dart +++ b/lib/src/utils/session_key.dart @@ -7,6 +7,10 @@ class SessionKey { Map indexes; InboundGroupSession inboundGroupSession; final String key; + List get forwardingCurve25519KeyChain => + content["forwarding_curve25519_key_chain"] ?? []; + String get senderClaimedEd25519Key => + content["sender_claimed_ed25519_key"] ?? ""; SessionKey({this.content, this.inboundGroupSession, this.key, this.indexes}); diff --git a/test/event_test.dart b/test/event_test.dart index 6886e87..574a648 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -209,5 +209,42 @@ void main() { expect(resp1, null); expect(resp2, "42"); }); + + test("requestKey", () async { + Client matrix = Client("testclient", debug: true); + matrix.httpClient = FakeMatrixApi(); + await matrix.checkServer("https://fakeServer.notExisting"); + await matrix.login("test", "1234"); + + Event event = Event.fromJson( + jsonObj, Room(id: "!1234:example.com", client: matrix)); + String exception; + try { + await event.requestKey(); + } catch (e) { + exception = e; + } + expect(exception, "Session key not unknown"); + + event = Event.fromJson({ + "event_id": id, + "sender": senderID, + "origin_server_ts": timestamp, + "type": "m.room.encrypted", + "room_id": "1234", + "status": 2, + "content": json.encode({ + "msgtype": "m.bad.encrypted", + "body": DecryptError.UNKNOWN_SESSION, + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "AwgAEnACgAkLmt6qF84IK++J7UDH2Za1YVchHyprqTqsg...", + "device_id": "RJYKSTBOIE", + "sender_key": "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA", + "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ" + }), + }, Room(id: "!1234:example.com", client: matrix)); + + await event.requestKey(); + }); }); } diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 2c1afcc..a562d54 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -58,7 +58,7 @@ class FakeMatrixApi extends MockClient { return Response(json.encode(res), 405); } } else if (method == "PUT" && - action.contains("/client/r0/sendToDevice/m.room.encrypted/")) { + action.contains("/client/r0/sendToDevice/")) { return Response(json.encode({}), 200); } else if (method == "GET" && action.contains("/client/r0/rooms/") && diff --git a/test/room_key_request_test.dart b/test/room_key_request_test.dart new file mode 100644 index 0000000..f7b1ce0c --- /dev/null +++ b/test/room_key_request_test.dart @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2019 Zender & Kurtz GbR. + * + * Authors: + * Christian Pauly + * Marcel Radzio + * + * This file is part of famedlysdk. + * + * famedlysdk is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * famedlysdk is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with famedlysdk. If not, see . + */ + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:test/test.dart'; + +import 'fake_matrix_api.dart'; +import 'fake_store.dart'; + +void main() { + /// All Tests related to device keys + group("Room Key Request", () { + test("fromJson", () async { + Map rawJson = { + "content": { + "action": "request", + "body": { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": "!726s6s6q:example.com", + "sender_key": "RF3s+E7RkTQTGF2d8Deol0FkQvgII2aJDf3/Jp5mxVU", + "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ" + }, + "request_id": "1495474790150.19", + "requesting_device_id": "JLAFKJWSCS" + }, + "type": "m.room_key_request", + "sender": "@alice:example.com" + }; + ToDeviceEvent toDeviceEvent = ToDeviceEvent.fromJson(rawJson); + expect(toDeviceEvent.content, rawJson["content"]); + expect(toDeviceEvent.sender, rawJson["sender"]); + expect(toDeviceEvent.type, rawJson["type"]); + + Client matrix = Client("testclient", debug: true); + matrix.httpClient = FakeMatrixApi(); + matrix.storeAPI = FakeStore(matrix, {}); + await matrix.checkServer("https://fakeServer.notExisting"); + await matrix.login("test", "1234"); + Room room = matrix.getRoomById("!726s6s6q:example.com"); + if (matrix.encryptionEnabled) { + await room.createOutboundGroupSession(); + rawJson["content"]["body"]["session_id"] = room.sessionKeys.keys.first; + + RoomKeyRequest roomKeyRequest = RoomKeyRequest.fromToDeviceEvent( + ToDeviceEvent.fromJson(rawJson), matrix); + await roomKeyRequest.forwardKey(); + } + }); + }); +}