/* * 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 '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'; import 'package:famedlysdk/src/store_api.dart'; 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'; import 'package:famedlysdk/src/utils/user_device.dart'; import 'package:olm/olm.dart' as olm; import 'package:pedantic/pedantic.dart'; import 'room.dart'; import 'event.dart'; import 'user.dart'; import 'utils/profile.dart'; import 'dart:convert'; import 'package:famedlysdk/src/room.dart'; import 'package:http/http.dart' as http; import 'package:mime_type/mime_type.dart'; import 'sync/event_update.dart'; import 'sync/room_update.dart'; import 'sync/user_update.dart'; import 'utils/matrix_exception.dart'; typedef RoomSorter = int Function(Room a, Room b); enum HTTPType { GET, POST, PUT, DELETE } enum LoginState { logged, loggedOut } /// Represents a Matrix client to communicate with a /// [Matrix](https://matrix.org) homeserver and is the entry point for this /// SDK. class Client { /// Handles the connection for this client. @deprecated Client get connection => this; /// Optional persistent store for all data. ExtendedStoreAPI get store => (storeAPI?.extended ?? false) ? storeAPI : null; StoreAPI storeAPI; Client(this.clientName, {this.debug = false, this.storeAPI}) { this.onLoginStateChanged.stream.listen((loginState) { print("LoginState: ${loginState.toString()}"); }); } /// Whether debug prints should be displayed. final bool debug; /// The required name for this client. final String clientName; /// The homeserver this client is communicating with. String get homeserver => _homeserver; String _homeserver; /// The Matrix ID of the current logged user. String get userID => _userID; String _userID; /// This is the access token for the matrix client. When it is undefined, then /// the user needs to sign in first. String get accessToken => _accessToken; String _accessToken; /// This points to the position in the synchronization history. String prevBatch; /// The device ID is an unique identifier for this device. String get deviceID => _deviceID; String _deviceID; /// The device name is a human readable identifier for this device. String get deviceName => _deviceName; String _deviceName; /// Which version of the matrix specification does this server support? List get matrixVersions => _matrixVersions; List _matrixVersions; /// Wheither the server supports lazy load members. bool get lazyLoadMembers => _lazyLoadMembers; bool _lazyLoadMembers = false; /// Returns the current login state. bool isLogged() => accessToken != null; /// A list of all rooms the user is participating or invited. 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!"); _rooms = newList; } /// Key/Value store of account data. Map accountData = {}; /// Presences of users by a given matrix ID Map presences = {}; int _timeoutFactor = 1; Room getRoomByAlias(String alias) { for (int i = 0; i < rooms.length; i++) { if (rooms[i].canonicalAlias == alias) return rooms[i]; } return null; } Room getRoomById(String id) { for (int j = 0; j < rooms.length; j++) { if (rooms[j].id == id) return rooms[j]; } return null; } void handleUserUpdate(UserUpdate userUpdate) { if (userUpdate.type == "account_data") { AccountData newAccountData = AccountData.fromJson(userUpdate.content); accountData[newAccountData.typeKey] = newAccountData; if (onAccountData != null) onAccountData.add(newAccountData); } if (userUpdate.type == "presence") { Presence newPresence = Presence.fromJson(userUpdate.content); presences[newPresence.sender] = newPresence; if (onPresence != null) onPresence.add(newPresence); } } Map get directChats => accountData["m.direct"] != null ? accountData["m.direct"].content : {}; /// Returns the (first) room ID from the store which is a private chat with the user [userId]. /// Returns null if there is none. String getDirectChatFromUserId(String userId) { if (accountData["m.direct"] != null && accountData["m.direct"].content[userId] is List && accountData["m.direct"].content[userId].length > 0) { if (getRoomById(accountData["m.direct"].content[userId][0]) != null) { return accountData["m.direct"].content[userId][0]; } (accountData["m.direct"].content[userId] as List) .remove(accountData["m.direct"].content[userId][0]); this.jsonRequest( type: HTTPType.PUT, action: "/client/r0/user/${userID}/account_data/m.direct", data: directChats); return getDirectChatFromUserId(userId); } for (int i = 0; i < this.rooms.length; i++) { if (this.rooms[i].membership == Membership.invite && this.rooms[i].states[userID]?.senderId == userId && this.rooms[i].states[userID].content["is_direct"] == true) { return this.rooms[i].id; } } return null; } /// Checks the supported versions of the Matrix protocol and the supported /// login types. Returns false if the server is not compatible with the /// client. Automatically sets [matrixVersions] and [lazyLoadMembers]. /// Throws FormatException, TimeoutException and MatrixException on error. Future checkServer(serverUrl) async { try { _homeserver = serverUrl; final versionResp = await this .jsonRequest(type: HTTPType.GET, action: "/client/versions"); final List versions = List.from(versionResp["versions"]); for (int i = 0; i < versions.length; i++) { if (versions[i] == "r0.5.0") { break; } else if (i == versions.length - 1) { return false; } } _matrixVersions = versions; if (versionResp.containsKey("unstable_features") && versionResp["unstable_features"].containsKey("m.lazy_load_members")) { _lazyLoadMembers = versionResp["unstable_features"] ["m.lazy_load_members"] ? true : false; } final loginResp = await this .jsonRequest(type: HTTPType.GET, action: "/client/r0/login"); final List flows = loginResp["flows"]; for (int i = 0; i < flows.length; i++) { if (flows[i].containsKey("type") && flows[i]["type"] == "m.login.password") { break; } else if (i == flows.length - 1) { return false; } } return true; } catch (_) { this._homeserver = this._matrixVersions = null; rethrow; } } /// Checks to see if a username is available, and valid, for the server. /// You have to call [checkServer] first to set a homeserver. Future usernameAvailable(String username) async { final Map response = await this.jsonRequest( type: HTTPType.GET, action: "/client/r0/register/available?username=$username", ); return response["available"]; } /// Checks to see if a username is available, and valid, for the server. /// Returns the fully-qualified Matrix user ID (MXID) that has been registered. /// You have to call [checkServer] first to set a homeserver. Future> register({ String kind, String username, String password, Map auth, String deviceId, String initialDeviceDisplayName, bool inhibitLogin, }) async { final String action = "/client/r0/register" + (kind != null ? "?kind=$kind" : ""); Map data = {}; if (username != null) data["username"] = username; if (password != null) data["password"] = password; if (auth != null) data["auth"] = auth; if (deviceId != null) data["device_id"] = deviceId; if (initialDeviceDisplayName != null) { data["initial_device_display_name"] = initialDeviceDisplayName; } if (inhibitLogin != null) data["inhibit_login"] = inhibitLogin; final Map response = await this.jsonRequest(type: HTTPType.POST, action: action, data: data); // Connect if there is an access token in the response. if (response.containsKey("access_token") && response.containsKey("device_id") && response.containsKey("user_id")) { await this.connect( newToken: response["access_token"], newUserID: response["user_id"], newHomeserver: homeserver, newDeviceName: initialDeviceDisplayName ?? "", newDeviceID: response["device_id"], newMatrixVersions: matrixVersions, newLazyLoadMembers: lazyLoadMembers); } return response; } /// Handles the login and allows the client to call all APIs which require /// authentication. Returns false if the login was not successful. Throws /// MatrixException if login was not successful. /// You have to call [checkServer] first to set a homeserver. Future login( String username, String password, { String initialDeviceDisplayName, String deviceId, }) async { Map data = { "type": "m.login.password", "user": username, "identifier": { "type": "m.id.user", "user": username, }, "password": password, }; if (deviceId != null) data["device_id"] = deviceId; if (initialDeviceDisplayName != null) { data["initial_device_display_name"] = initialDeviceDisplayName; } final loginResp = await jsonRequest( type: HTTPType.POST, action: "/client/r0/login", data: data); if (loginResp.containsKey("user_id") && 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, ); return true; } return false; } /// Sends a logout command to the homeserver and clears all local data, /// including all persistent data from the store. Future logout() async { try { await this.jsonRequest(type: HTTPType.POST, action: "/client/r0/logout"); } catch (exception) { rethrow; } finally { await this.clear(); } } /// Returns the user's own displayname and avatar url. In Matrix it is possible that /// one user can have different displaynames and avatar urls in different rooms. So /// this endpoint first checks if the profile is the same in all rooms. If not, the /// profile will be requested from the homserver. Future get ownProfile async { if (rooms.isNotEmpty) { Set profileSet = {}; for (Room room in rooms) { final user = room.getUserByMXIDSync(userID); profileSet.add(Profile.fromJson(user.content)); } if (profileSet.length == 1) return profileSet.first; } return getProfileFromUserId(userID); } /// Get the combined profile information for this user. This API may be used to /// fetch the user's own profile information or other users; either locally /// or on remote homeservers. Future getProfileFromUserId(String userId) async { final dynamic resp = await this.jsonRequest( type: HTTPType.GET, action: "/client/r0/profile/${userId}"); return Profile.fromJson(resp); } Future> get archive async { List archiveList = []; String syncFilters = '{"room":{"include_leave":true,"timeline":{"limit":10}}}'; String action = "/client/r0/sync?filter=$syncFilters&timeout=0"; final sync = await this.jsonRequest(type: HTTPType.GET, action: action); if (sync["rooms"]["leave"] is Map) { for (var entry in sync["rooms"]["leave"].entries) { final String id = entry.key; final dynamic room = entry.value; Room leftRoom = Room( id: id, membership: Membership.leave, client: this, roomAccountData: {}, mHeroes: []); if (room["account_data"] is Map && room["account_data"]["events"] is List) { for (dynamic event in room["account_data"]["events"]) { leftRoom.roomAccountData[event["type"]] = RoomAccountData.fromJson(event, leftRoom); } } if (room["timeline"] is Map && room["timeline"]["events"] is List) { for (dynamic event in room["timeline"]["events"]) { leftRoom.setState(Event.fromJson(event, leftRoom)); } } if (room["state"] is Map && room["state"]["events"] is List) { for (dynamic event in room["state"]["events"]) { leftRoom.setState(Event.fromJson(event, leftRoom)); } } archiveList.add(leftRoom); } } return archiveList; } Future joinRoomById(String id) async { return await this .jsonRequest(type: HTTPType.POST, action: "/client/r0/join/$id"); } /// Loads the contact list for this user excluding the user itself. /// Currently the contacts are found by discovering the contacts of /// the famedlyContactDiscovery room, which is /// defined by the autojoin room feature in Synapse. Future> loadFamedlyContacts() async { List contacts = []; Room contactDiscoveryRoom = this.getRoomByAlias("#famedlyContactDiscovery:${userID.domain}"); if (contactDiscoveryRoom != null) { contacts = await contactDiscoveryRoom.requestParticipants(); } else { Map userMap = {}; for (int i = 0; i < this.rooms.length; i++) { List roomUsers = this.rooms[i].getParticipants(); for (int j = 0; j < roomUsers.length; j++) { if (userMap[roomUsers[j].id] != true) contacts.add(roomUsers[j]); userMap[roomUsers[j].id] = true; } } } return contacts; } @Deprecated('Please use [createRoom] instead!') Future createGroup(List users) => createRoom(invite: users); /// Creates a new group chat and invites the given Users and returns the new /// created room ID. If [params] are provided, invite will be ignored. For the /// moment please look at https://matrix.org/docs/spec/client_server/r0.5.0#post-matrix-client-r0-createroom /// to configure [params]. Future createRoom( {List invite, Map params}) async { List inviteIDs = []; if (params == null && invite != null) { for (int i = 0; i < invite.length; i++) { inviteIDs.add(invite[i].id); } } try { final dynamic resp = await this.jsonRequest( type: HTTPType.POST, action: "/client/r0/createRoom", data: params == null ? { "invite": inviteIDs, } : params); return resp["room_id"]; } catch (e) { rethrow; } } /// Changes the user's displayname. Future setDisplayname(String displayname) async { await this.jsonRequest( type: HTTPType.PUT, action: "/client/r0/profile/$userID/displayname", data: {"displayname": displayname}); return; } /// Uploads a new user avatar for this user. Future setAvatar(MatrixFile file) async { final uploadResp = await this.upload(file); await this.jsonRequest( type: HTTPType.PUT, action: "/client/r0/profile/$userID/avatar_url", data: {"avatar_url": uploadResp}); return; } /// Get credentials for the client to use when initiating calls. Future getTurnServerCredentials() async { final Map response = await this.jsonRequest( type: HTTPType.GET, action: "/client/r0/voip/turnServer", ); return TurnServerCredentials.fromJson(response); } /// Fetches the pushrules for the logged in user. /// These are needed for notifications on Android @Deprecated("Use [pushRules] instead.") Future getPushrules() async { final dynamic resp = await this.jsonRequest( type: HTTPType.GET, action: "/client/r0/pushrules/", ); return PushRules.fromJson(resp); } /// Returns the push rules for the logged in user. PushRules get pushRules => accountData.containsKey("m.push_rules") ? PushRules.fromJson(accountData["m.push_rules"].content) : null; /// This endpoint allows the creation, modification and deletion of pushers for this user ID. Future setPushers(String pushKey, String kind, String appId, String appDisplayName, String deviceDisplayName, String lang, String url, {bool append, String profileTag, String format}) async { Map data = { "lang": lang, "kind": kind, "app_display_name": appDisplayName, "device_display_name": deviceDisplayName, "profile_tag": profileTag, "app_id": appId, "pushkey": pushKey, "data": {"url": url} }; if (format != null) data["data"]["format"] = format; if (profileTag != null) data["profile_tag"] = profileTag; if (append != null) data["append"] = append; await this.jsonRequest( type: HTTPType.POST, action: "/client/r0/pushers/set", data: data, ); return; } static String syncFilters = '{"room":{"state":{"lazy_load_members":true}}}'; static const List supportedDirectEncryptionAlgorithms = [ "m.olm.v1.curve25519-aes-sha2" ]; static const List supportedGroupEncryptionAlgorithms = [ "m.megolm.v1.aes-sha2" ]; http.Client httpClient = http.Client(); /// The newEvent signal is the most important signal in this concept. Every time /// the app receives a new synchronization, this event is called for every signal /// to update the GUI. For example, for a new message, it is called: /// onRoomEvent( "m.room.message", "!chat_id:server.com", "timeline", {sender: "@bob:server.com", body: "Hello world"} ) final StreamController onEvent = StreamController.broadcast(); /// Outside of the events there are updates for the global chat states which /// are handled by this signal: final StreamController onRoomUpdate = StreamController.broadcast(); /// 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(); /// Synchronization erros are coming here. final StreamController onError = StreamController.broadcast(); /// This is called once, when the first sync has received. final StreamController onFirstSync = StreamController.broadcast(); /// When a new sync response is coming in, this gives the complete payload. final StreamController onSync = StreamController.broadcast(); /// Callback will be called on presences. final StreamController onPresence = StreamController.broadcast(); /// Callback will be called on account data updates. final StreamController onAccountData = StreamController.broadcast(); /// Will be called on call invites. final StreamController onCallInvite = StreamController.broadcast(); /// Will be called on call hangups. final StreamController onCallHangup = StreamController.broadcast(); /// Will be called on call candidates. final StreamController onCallCandidates = StreamController.broadcast(); /// 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; /// How long should the app wait until it retrys the synchronisation after /// an error? int syncErrorTimeoutSec = 3; /// Sets the user credentials and starts the synchronisation. /// /// Before you can connect you need at least an [accessToken], a [homeserver], /// a [userID], a [deviceID], and a [deviceName]. /// /// You get this informations /// by logging in to your Matrix account, using the [login API](https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-login). /// /// To log in you can use [jsonRequest()] after you have set the [homeserver] /// to a valid url. For example: /// /// ``` /// final resp = await matrix /// .jsonRequest(type: HTTPType.POST, action: "/client/r0/login", data: { /// "type": "m.login.password", /// "user": "test", /// "password": "1234", /// "initial_device_display_name": "Fluffy Matrix Client" /// }); /// ``` /// /// Returns: /// /// ``` /// { /// "user_id": "@cheeky_monkey:matrix.org", /// "access_token": "abc123", /// "device_id": "GHTYAJCE" /// } /// ``` /// /// Sends [LoginState.logged] to [onLoginStateChanged]. void connect({ String newToken, String newHomeserver, String newUserID, String newDeviceName, String newDeviceID, List newMatrixVersions, bool newLazyLoadMembers, String newPrevBatch, String newOlmAccount, }) async { this._accessToken = newToken; this._homeserver = newHomeserver; this._userID = newUserID; this._deviceID = newDeviceID; this._deviceName = newDeviceName; this._matrixVersions = newMatrixVersions; 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(); if (await this._uploadKeys(uploadDeviceKeys: true) == false) { throw ("Upload key failed"); } } 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(); this.accountData = await this.store.getAccountData(); this.presences = await this.store.getPresences(); } } _userEventSub ??= onUserEvent.stream.listen(this.handleUserUpdate); onLoginStateChanged.add(LoginState.logged); return _sync(); } StreamSubscription _userEventSub; /// Resets all settings and stops the synchronisation. void clear() { olmSessions.values.forEach((List sessions) { sessions.forEach((olm.Session session) => session?.free()); }); rooms.forEach((Room room) { room.clearOutboundGroupSession(); room.sessionKeys.values.forEach((SessionKey sessionKey) { sessionKey.inboundGroupSession?.free(); }); }); this._olmAccount?.free(); this.storeAPI?.clear(); this._accessToken = this._homeserver = this._userID = this._deviceID = this ._deviceName = this._matrixVersions = this._lazyLoadMembers = this.prevBatch = null; onLoginStateChanged.add(LoginState.loggedOut); } /// Used for all Matrix json requests using the [c2s API](https://matrix.org/docs/spec/client_server/r0.4.0.html). /// /// Throws: TimeoutException, FormatException, MatrixException /// /// You must first call [this.connect()] or set [this.homeserver] before you can use /// this! For example to send a message to a Matrix room with the id /// '!fjd823j:example.com' you call: /// /// ``` /// final resp = await jsonRequest( /// type: HTTPType.PUT, /// action: "/r0/rooms/!fjd823j:example.com/send/m.room.message/$txnId", /// data: { /// "msgtype": "m.text", /// "body": "hello" /// } /// ); /// ``` /// Future> jsonRequest( {HTTPType type, String action, dynamic data = "", int timeout, String contentType = "application/json"}) async { if (this.isLogged() == false && this.homeserver == null) { throw ("No homeserver specified."); } if (timeout == null) timeout = (_timeoutFactor * syncTimeoutSec) + 5; dynamic json; if (data is Map) data.removeWhere((k, v) => v == null); (!(data is String)) ? json = jsonEncode(data) : json = data; if (data is List || action.startsWith("/media/r0/upload")) json = data; final url = "${this.homeserver}/_matrix${action}"; Map headers = {}; if (type == HTTPType.PUT || type == HTTPType.POST) { headers["Content-Type"] = contentType; } if (this.isLogged()) { headers["Authorization"] = "Bearer ${this.accessToken}"; } if (this.debug) { print( "[REQUEST ${type.toString().split('.').last}] Action: $action, Data: $data"); } http.Response resp; Map jsonResp = {}; try { switch (type.toString().split('.').last) { case "GET": resp = await httpClient .get(url, headers: headers) .timeout(Duration(seconds: timeout)); break; case "POST": resp = await httpClient .post(url, body: json, headers: headers) .timeout(Duration(seconds: timeout)); break; case "PUT": resp = await httpClient .put(url, body: json, headers: headers) .timeout(Duration(seconds: timeout)); break; case "DELETE": resp = await httpClient .delete(url, headers: headers) .timeout(Duration(seconds: timeout)); break; } jsonResp = jsonDecode(String.fromCharCodes(resp.body.runes)) as Map; // May throw FormatException if (resp.statusCode >= 400 && resp.statusCode < 500) { // The server has responsed with an matrix related error. MatrixException exception = MatrixException(resp); if (exception.error == MatrixError.M_UNKNOWN_TOKEN) { // The token is no longer valid. Need to sign off.... onError.add(exception); clear(); } throw exception; } if (this.debug) print("[RESPONSE] ${jsonResp.toString()}"); } on ArgumentError catch (exception) { print(exception); // Ignore this error } on TimeoutException catch (_) { _timeoutFactor *= 2; rethrow; } catch (_) { print(_); rethrow; } return jsonResp; } /// Uploads a file with the name [fileName] as base64 encoded to the server /// and returns the mxc url as a string. Future upload(MatrixFile file) async { dynamic fileBytes; // For testing if (this.homeserver.toLowerCase() == "https://fakeserver.notexisting") { return "mxc://example.com/AQwafuaFswefuhsfAFAgsw"; } Map headers = {}; headers["Authorization"] = "Bearer $accessToken"; headers["Content-Type"] = mime(file.path); String fileName = file.path.split("/").last.toLowerCase(); final url = "$homeserver/_matrix/media/r0/upload?filename=$fileName"; final streamedRequest = http.StreamedRequest('POST', Uri.parse(url)) ..headers.addAll(headers); streamedRequest.contentLength = await file.bytes.length; streamedRequest.sink.add(file.bytes); streamedRequest.sink.close(); print("[UPLOADING] $fileName, size: ${fileBytes?.length}"); var streamedResponse = await streamedRequest.send(); Map jsonResponse = json.decode( String.fromCharCodes(await streamedResponse.stream.first), ); return jsonResponse["content_uri"]; } Future _syncRequest; Future _sync() async { if (this.isLogged() == false) return; String action = "/client/r0/sync?filter=$syncFilters"; if (this.prevBatch != null) { action += "&timeout=30000"; action += "&since=${this.prevBatch}"; } try { _syncRequest = jsonRequest(type: HTTPType.GET, action: action); final int hash = _syncRequest.hashCode; final syncResp = await _syncRequest; if (hash != _syncRequest.hashCode) return; _timeoutFactor = 1; if (this.store != null) { await this.store.transaction(() { handleSync(syncResp); this.store.storePrevBatch(syncResp["next_batch"]); }); } else { await handleSync(syncResp); } if (this.prevBatch == null) { this.onFirstSync.add(true); this.prevBatch = syncResp["next_batch"]; _sortRooms(); } this.prevBatch = syncResp["next_batch"]; await _updateUserDeviceKeys(); if (hash == _syncRequest.hashCode) unawaited(_sync()); } on MatrixException catch (exception) { onError.add(exception); await Future.delayed(Duration(seconds: syncErrorTimeoutSec), _sync); } catch (exception) { await Future.delayed(Duration(seconds: syncErrorTimeoutSec), _sync); } } /// Use this method only for testing utilities! void handleSync(dynamic sync) { if (sync["rooms"] is Map) { if (sync["rooms"]["join"] is Map) { _handleRooms(sync["rooms"]["join"], Membership.join); } if (sync["rooms"]["invite"] is Map) { _handleRooms(sync["rooms"]["invite"], Membership.invite); } if (sync["rooms"]["leave"] is Map) { _handleRooms(sync["rooms"]["leave"], Membership.leave); } } if (sync["presence"] is Map && sync["presence"]["events"] is List) { _handleGlobalEvents(sync["presence"]["events"], "presence"); } if (sync["account_data"] is Map && sync["account_data"]["events"] is List) { _handleGlobalEvents(sync["account_data"]["events"], "account_data"); } if (sync["to_device"] is Map && sync["to_device"]["events"] is List) { _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"]) { if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys[userId].outdated = true; } } for (final userId in deviceLists["left"]) { _clearOutboundGroupSessionsByUserId(userId); if (_userDeviceKeys.containsKey(userId)) { _userDeviceKeys.remove(userId); } } } } 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 from ${toDeviceEvent.sender}: " + e.toString()); print(toDeviceEvent.sender); 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 num highlight_count = 0; num notification_count = 0; String prev_batch = ""; bool limitedTimeline = false; if (room["unread_notifications"] is Map) { if (room["unread_notifications"]["highlight_count"] is num) { highlight_count = room["unread_notifications"]["highlight_count"]; } if (room["unread_notifications"]["notification_count"] is num) { notification_count = room["unread_notifications"]["notification_count"]; } } if (room["timeline"] is Map) { if (room["timeline"]["limited"] is bool) { limitedTimeline = room["timeline"]["limited"]; } if (room["timeline"]["prev_batch"] is String) { prev_batch = room["timeline"]["prev_batch"]; } } RoomSummary summary; if (room["summary"] is Map) { summary = RoomSummary.fromJson(room["summary"]); } RoomUpdate update = RoomUpdate( id: id, membership: membership, notification_count: notification_count, highlight_count: highlight_count, limitedTimeline: limitedTimeline, prev_batch: prev_batch, summary: summary, ); _updateRoomsByRoomUpdate(update); unawaited(this.store?.storeRoomUpdate(update)); onRoomUpdate.add(update); /// Handle now all room events and save them in the database if (room["state"] is Map && room["state"]["events"] is List) { _handleRoomEvents(id, room["state"]["events"], "state"); } if (room["invite_state"] is Map && room["invite_state"]["events"] is List) { _handleRoomEvents(id, room["invite_state"]["events"], "invite_state"); } if (room["timeline"] is Map && room["timeline"]["events"] is List) { _handleRoomEvents(id, room["timeline"]["events"], "timeline"); } if (room["ephemeral"] is Map && room["ephemeral"]["events"] is List) { _handleEphemerals(id, room["ephemeral"]["events"]); } if (room["account_data"] is Map && room["account_data"]["events"] is List) { _handleRoomEvents(id, room["account_data"]["events"], "account_data"); } }); } void _handleEphemerals(String id, List events) { for (num i = 0; i < events.length; i++) { _handleEvent(events[i], id, "ephemeral"); // Receipt events are deltas between two states. We will create a // fake room account data event for this and store the difference // there. if (events[i]["type"] == "m.receipt") { Room room = this.getRoomById(id); if (room == null) room = Room(id: id); Map receiptStateContent = room.roomAccountData["m.receipt"]?.content ?? {}; for (var eventEntry in events[i]["content"].entries) { final String eventID = eventEntry.key; if (events[i]["content"][eventID]["m.read"] != null) { final Map userTimestampMap = events[i]["content"][eventID]["m.read"]; for (var userTimestampMapEntry in userTimestampMap.entries) { final String mxid = userTimestampMapEntry.key; // Remove previous receipt event from this user for (var entry in receiptStateContent.entries) { if (entry.value["m.read"] is Map && entry.value["m.read"].containsKey(mxid)) { entry.value["m.read"].remove(mxid); break; } } if (userTimestampMap[mxid] is Map && userTimestampMap[mxid].containsKey("ts")) { receiptStateContent[mxid] = { "event_id": eventID, "ts": userTimestampMap[mxid]["ts"], }; } } } } events[i]["content"] = receiptStateContent; _handleEvent(events[i], id, "account_data"); } } } void _handleRoomEvents(String chat_id, List events, String type) { for (num i = 0; i < events.length; i++) { _handleEvent(events[i], chat_id, type); } } void _handleGlobalEvents(List events, String type) { for (int i = 0; i < events.length; i++) { if (events[i]["type"] is String && events[i]["content"] is Map) { UserUpdate update = UserUpdate( eventType: events[i]["type"], type: type, content: events[i], ); this.store?.storeUserEventUpdate(update); onUserEvent.add(update); } } } void _handleEvent(Map event, String roomID, String type) { if (event["type"] is String && event["content"] is Map) { // The client must ignore any new m.room.encryption event to prevent // man-in-the-middle attacks! if (event["type"] == "m.room.encryption" && getRoomById(roomID).encrypted) { return; } EventUpdate update = EventUpdate( eventType: event["type"], roomID: roomID, type: type, content: event, ); if (event["type"] == "m.room.encrypted") { update = update.decrypt(this.getRoomById(update.roomID)); } this.store?.storeEventUpdate(update); _updateRoomsByEventUpdate(update); onEvent.add(update); if (event["type"] == "m.call.invite") { onCallInvite.add(Event.fromJson(event, getRoomById(roomID))); } else if (event["type"] == "m.call.hangup") { onCallHangup.add(Event.fromJson(event, getRoomById(roomID))); } else if (event["type"] == "m.call.answer") { onCallAnswer.add(Event.fromJson(event, getRoomById(roomID))); } else if (event["type"] == "m.call.candidates") { onCallCandidates.add(Event.fromJson(event, getRoomById(roomID))); } } } void _updateRoomsByRoomUpdate(RoomUpdate chatUpdate) { // Update the chat list item. // Search the room in the rooms num j = 0; for (j = 0; j < rooms.length; j++) { if (rooms[j].id == chatUpdate.id) break; } final bool found = (j < rooms.length && rooms[j].id == chatUpdate.id); final bool isLeftRoom = chatUpdate.membership == Membership.leave; // Does the chat already exist in the list rooms? if (!found && !isLeftRoom) { num position = chatUpdate.membership == Membership.invite ? 0 : j; // Add the new chat to the list Room newRoom = Room( id: chatUpdate.id, membership: chatUpdate.membership, prev_batch: chatUpdate.prev_batch, highlightCount: chatUpdate.highlight_count, notificationCount: chatUpdate.notification_count, mHeroes: chatUpdate.summary?.mHeroes, mJoinedMemberCount: chatUpdate.summary?.mJoinedMemberCount, mInvitedMemberCount: chatUpdate.summary?.mInvitedMemberCount, roomAccountData: {}, client: this, ); newRoom.restoreGroupSessionKeys(); rooms.insert(position, newRoom); } // If the membership is "leave" then remove the item and stop here else if (found && isLeftRoom) { rooms.removeAt(j); } // Update notification, highlight count and/or additional informations else if (found && chatUpdate.membership != Membership.leave && (rooms[j].membership != chatUpdate.membership || rooms[j].notificationCount != chatUpdate.notification_count || rooms[j].highlightCount != chatUpdate.highlight_count || chatUpdate.summary != null)) { rooms[j].membership = chatUpdate.membership; rooms[j].notificationCount = chatUpdate.notification_count; rooms[j].highlightCount = chatUpdate.highlight_count; if (chatUpdate.prev_batch != null) { rooms[j].prev_batch = chatUpdate.prev_batch; } if (chatUpdate.summary != null) { if (chatUpdate.summary.mHeroes != null) { rooms[j].mHeroes = chatUpdate.summary.mHeroes; } if (chatUpdate.summary.mJoinedMemberCount != null) { rooms[j].mJoinedMemberCount = chatUpdate.summary.mJoinedMemberCount; } if (chatUpdate.summary.mInvitedMemberCount != null) { rooms[j].mInvitedMemberCount = chatUpdate.summary.mInvitedMemberCount; } } if (rooms[j].onUpdate != null) rooms[j].onUpdate.add(rooms[j].id); } _sortRooms(); } void _updateRoomsByEventUpdate(EventUpdate eventUpdate) { if (eventUpdate.type == "history") return; // Search the room in the rooms num j = 0; for (j = 0; j < rooms.length; j++) { if (rooms[j].id == eventUpdate.roomID) break; } final bool found = (j < rooms.length && rooms[j].id == eventUpdate.roomID); if (!found) return; if (eventUpdate.type == "timeline" || eventUpdate.type == "state" || eventUpdate.type == "invite_state") { Event stateEvent = Event.fromJson(eventUpdate.content, rooms[j]); if (stateEvent.type == EventTypes.Redaction) { final String redacts = eventUpdate.content["redacts"]; rooms[j].states.states.forEach( (String key, Map states) => states.forEach( (String key, Event state) { if (state.eventId == redacts) { state.setRedactionEvent(stateEvent); } }, ), ); } else { Event prevState = rooms[j].getState(stateEvent.typeKey, stateEvent.stateKey); if (prevState != null && prevState.time.millisecondsSinceEpoch > stateEvent.time.millisecondsSinceEpoch) return; rooms[j].setState(stateEvent); } } else if (eventUpdate.type == "account_data") { rooms[j].roomAccountData[eventUpdate.eventType] = RoomAccountData.fromJson(eventUpdate.content, rooms[j]); } else if (eventUpdate.type == "ephemeral") { rooms[j].ephemerals[eventUpdate.eventType] = RoomAccountData.fromJson(eventUpdate.content, rooms[j]); } if (rooms[j].onUpdate != null) rooms[j].onUpdate.add(rooms[j].id); if (eventUpdate.type == "timeline") _sortRooms(); } void _updateRoomsByToDeviceEvent(ToDeviceEvent toDeviceEvent) { try { switch (toDeviceEvent.type) { case "m.room_key": case "m.forwarded_room_key": Room room = getRoomById(toDeviceEvent.content["room_id"]); if (room != null) { final String sessionId = toDeviceEvent.content["session_id"]; if (room != null) { 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("[Matrix] Error while processing to-device-event: " + e.toString()); } } bool _sortLock = false; /// The compare function how the rooms should be sorted internally. By default /// rooms are sorted by timestamp of the last m.room.message event or the last /// event if there is no known message. RoomSorter sortRoomsBy = (a, b) => b.timeCreated.millisecondsSinceEpoch .compareTo(a.timeCreated.millisecondsSinceEpoch); _sortRooms() { if (prevBatch == null || _sortLock || rooms.length < 2) return; _sortLock = true; rooms?.sort(sortRoomsBy); _sortLock = false; } /// Gets an OpenID token object that the requester may supply to another service to verify their identity in Matrix. /// The generated token is only valid for exchanging for user information from the federation API for OpenID. Future requestOpenIdCredentials() async { final Map response = await jsonRequest( type: HTTPType.POST, action: "/client/r0/user/$userID/openid/request_token", data: {}, ); return OpenIdCredentials.fromJson(response); } /// A map of known device keys per user. Map get userDeviceKeys => _userDeviceKeys; Map _userDeviceKeys = {}; Future> _getUserIdsInEncryptedRooms() async { Set userIds = {}; for (int i = 0; i < rooms.length; i++) { if (rooms[i].encrypted) { List userList = await rooms[i].requestParticipants(); for (User user in userList) { if ([Membership.join, Membership.invite].contains(user.membership)) { userIds.add(user.id); } } } } return userIds; } Future _updateUserDeviceKeys() async { try { if (!this.isLogged()) return; Set trackedUserIds = await _getUserIdsInEncryptedRooms(); trackedUserIds.add(this.userID); // Remove all userIds we no longer need to track the devices of. _userDeviceKeys .removeWhere((String userId, v) => !trackedUserIds.contains(userId)); // Check if there are outdated device key lists. Add it to the set. Map outdatedLists = {}; for (String userId in trackedUserIds) { if (!userDeviceKeys.containsKey(userId)) { _userDeviceKeys[userId] = DeviceKeysList(userId); } DeviceKeysList deviceKeysList = userDeviceKeys[userId]; if (deviceKeysList.outdated) { outdatedLists[userId] = []; } } if (outdatedLists.isNotEmpty) { // Request the missing device key lists from the server. final Map response = await this.jsonRequest( type: HTTPType.POST, action: "/client/r0/keys/query", data: {"timeout": 10000, "device_keys": outdatedLists}); for (final rawDeviceKeyListEntry in response["device_keys"].entries) { final String userId = rawDeviceKeyListEntry.key; final Map oldKeys = Map.from(_userDeviceKeys[userId].deviceKeys); _userDeviceKeys[userId].deviceKeys = {}; for (final rawDeviceKeyEntry in rawDeviceKeyListEntry.value.entries) { final String deviceId = rawDeviceKeyEntry.key; // Set the new device key for this device if (!oldKeys.containsKey(deviceId)) { _userDeviceKeys[userId].deviceKeys[deviceId] = DeviceKeys.fromJson(rawDeviceKeyEntry.value); if (deviceId == this.deviceID && _userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key == this.fingerprintKey) { // Always trust the own device _userDeviceKeys[userId].deviceKeys[deviceId].verified = true; } } else { _userDeviceKeys[userId].deviceKeys[deviceId] = oldKeys[deviceId]; } } _userDeviceKeys[userId].outdated = false; if (_userDeviceKeys[userId].deviceKeys.toString() != oldKeys.toString()) { _clearOutboundGroupSessionsByUserId(userId); } } } await this.storeAPI?.storeUserDeviceKeys(userDeviceKeys); } catch (e) { print("[LibOlm] Unable to update user device keys: " + e.toString()); } } 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) != 0) { plaintext = session.decrypt(type, body); storeOlmSession(senderKey, session); break; } else if (type == 1) { try { plaintext = session.decrypt(type, body); storeOlmSession(senderKey, session); break; } catch (_) { plaintext = null; } } } } 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(); plaintext = newSession.decrypt(type, body); storeOlmSession(senderKey, newSession); } 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] = []; } if (_olmSessions[curve25519IdentityKey] .indexWhere((s) => s.session_id() == session.session_id()) == -1) { _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]. 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.isNotEmpty) { deviceKeys.removeWhere((DeviceKeys deviceKeys) => deviceKeys.blocked || deviceKeys.deviceId == deviceID); if (deviceKeys.isEmpty) return; } 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(), }; 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; } } final String messageID = "msg${DateTime.now().millisecondsSinceEpoch}"; await jsonRequest( type: HTTPType.PUT, action: "/client/r0/sendToDevice/$type/$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()); } } } } } /// Gets information about all devices for the current user. Future> requestUserDevices() async { final Map response = await jsonRequest(type: HTTPType.GET, action: "/client/r0/devices"); List userDevices = []; for (final rawDevice in response["devices"]) { userDevices.add( UserDevice.fromJson(rawDevice, this), ); } return userDevices; } /// Gets information about all devices for the current user. Future requestUserDevice(String deviceId) async { final Map response = await jsonRequest( type: HTTPType.GET, action: "/client/r0/devices/$deviceId"); return UserDevice.fromJson(response, this); } /// Deletes the given devices, and invalidates any access token associated with them. Future deleteDevices(List deviceIds, {Map auth}) async { await jsonRequest( type: HTTPType.POST, action: "/client/r0/delete_devices", data: { "devices": deviceIds, if (auth != null) "auth": auth, }, ); return; } }