/* * Famedly Matrix SDK * Copyright (C) 2019, 2020, 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:convert'; import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/encryption/utils/stored_inbound_group_session.dart'; import 'package:olm/olm.dart' as olm; import 'package:collection/collection.dart'; import './encryption.dart'; import './utils/outbound_group_session.dart'; import './utils/session_key.dart'; import '../matrix.dart'; import '../src/utils/run_in_root.dart'; const megolmKey = EventTypes.MegolmBackup; class KeyManager { final Encryption encryption; Client get client => encryption.client; final outgoingShareRequests = {}; final incomingShareRequests = {}; final _inboundGroupSessions = >{}; final _outboundGroupSessions = {}; final Set _loadedOutboundGroupSessions = {}; final Set _requestedSessionIds = {}; KeyManager(this.encryption) { encryption.ssss.setValidator(megolmKey, (String secret) async { final keyObj = olm.PkDecryption(); try { final info = await getRoomKeysBackupInfo(false); if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2) { return false; } return keyObj.init_with_private_key(base64decodeUnpadded(secret)) == info.authData['public_key']; } catch (_) { return false; } finally { keyObj.free(); } }); encryption.ssss.setCacheCallback(megolmKey, (String secret) { // we got a megolm key cached, clear our requested keys and try to re-decrypt // last events _requestedSessionIds.clear(); for (final room in client.rooms) { final lastEvent = room.lastEvent; if (lastEvent != null && lastEvent.type == EventTypes.Encrypted && lastEvent.content['can_request_session'] == true) { try { maybeAutoRequest(room.id, lastEvent.content['session_id'], lastEvent.content['sender_key']); } catch (_) { // dispose } } } }); } bool get enabled => encryption.ssss.isSecret(megolmKey); /// clear all cached inbound group sessions. useful for testing void clearInboundGroupSessions() { _inboundGroupSessions.clear(); } void setInboundGroupSession( String roomId, String sessionId, String senderKey, Map content, { bool forwarded = false, Map? senderClaimedKeys, bool uploaded = false, Map>? allowedAtIndex, }) { final senderClaimedKeys_ = senderClaimedKeys ?? {}; final allowedAtIndex_ = allowedAtIndex ?? >{}; final userId = client.userID; if (userId == null) return; if (!senderClaimedKeys_.containsKey('ed25519')) { final device = client.getUserDeviceKeysByCurve25519Key(senderKey); if (device != null && device.ed25519Key != null) { senderClaimedKeys_['ed25519'] = device.ed25519Key!; } } final oldSession = getInboundGroupSession(roomId, sessionId, senderKey, otherRooms: false); if (content['algorithm'] != AlgorithmTypes.megolmV1AesSha2) { return; } late olm.InboundGroupSession inboundGroupSession; try { inboundGroupSession = olm.InboundGroupSession(); if (forwarded) { inboundGroupSession.import_session(content['session_key']); } else { inboundGroupSession.create(content['session_key']); } } catch (e, s) { inboundGroupSession.free(); Logs().e('[LibOlm] Could not create new InboundGroupSession', e, s); return; } final newSession = SessionKey( content: content, inboundGroupSession: inboundGroupSession, indexes: {}, roomId: roomId, sessionId: sessionId, key: userId, senderKey: senderKey, senderClaimedKeys: senderClaimedKeys_, allowedAtIndex: allowedAtIndex_, ); final oldFirstIndex = oldSession?.inboundGroupSession?.first_known_index() ?? 0; final newFirstIndex = newSession.inboundGroupSession!.first_known_index(); if (oldSession == null || newFirstIndex < oldFirstIndex || (oldFirstIndex == newFirstIndex && newSession.forwardingCurve25519KeyChain.length < oldSession.forwardingCurve25519KeyChain.length)) { // use new session oldSession?.dispose(); } else { // we are gonna keep our old session newSession.dispose(); return; } final roomInboundGroupSessions = _inboundGroupSessions[roomId] ??= {}; roomInboundGroupSessions[sessionId] = newSession; if (!client.isLogged() || client.encryption == null) { return; } client.database ?.storeInboundGroupSession( roomId, sessionId, inboundGroupSession.pickle(userId), json.encode(content), json.encode({}), json.encode(allowedAtIndex_), senderKey, json.encode(senderClaimedKeys_), ) .then((_) { if (!client.isLogged() || client.encryption == null) { return; } if (uploaded) { client.database?.markInboundGroupSessionAsUploaded(roomId, sessionId); } else { _haveKeysToUpload = true; } }); final room = client.getRoomById(roomId); if (room != null) { // attempt to decrypt the last event final event = room.getState(EventTypes.Encrypted); if (event != null && event.content['session_id'] == sessionId) { room.setState(encryption.decryptRoomEventSync(roomId, event)); } // and finally broadcast the new session room.onSessionKeyReceived.add(sessionId); } } SessionKey? getInboundGroupSession( String roomId, String sessionId, String senderKey, {bool otherRooms = true}) { final sess = _inboundGroupSessions[roomId]?[sessionId]; if (sess != null) { if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) { return null; } return sess; } if (!otherRooms) { return null; } // search if this session id is *somehow* found in another room for (final val in _inboundGroupSessions.values) { final sess = val[sessionId]; if (sess != null) { if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) { return null; } return sess; } } return null; } /// Attempt auto-request for a key void maybeAutoRequest(String roomId, String sessionId, String senderKey) { final room = client.getRoomById(roomId); final requestIdent = '$roomId|$sessionId|$senderKey'; if (room != null && !_requestedSessionIds.contains(requestIdent) && !client.isUnknownSession) { // do e2ee recovery _requestedSessionIds.add(requestIdent); runInRoot( () => request(room, sessionId, senderKey, onlineKeyBackupOnly: true)); } } /// Loads an inbound group session Future loadInboundGroupSession( String roomId, String sessionId, String senderKey) async { final sess = _inboundGroupSessions[roomId]?[sessionId]; if (sess != null) { if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) { return null; // sender keys do not match....better not do anything } return sess; // nothing to do } final session = await client.database?.getInboundGroupSession(roomId, sessionId); if (session == null) return null; final userID = client.userID; if (userID == null) return null; final dbSess = SessionKey.fromDb(session, userID); final roomInboundGroupSessions = _inboundGroupSessions[roomId] ??= {}; if (!dbSess.isValid || dbSess.senderKey.isEmpty || dbSess.senderKey != senderKey) { return null; } roomInboundGroupSessions[sessionId] = dbSess; return sess; } Map> _getDeviceKeyIdMap( List deviceKeys) { final deviceKeyIds = >{}; for (final device in deviceKeys) { final deviceId = device.deviceId; if (deviceId == null) { Logs().w('[KeyManager] ignoring device without deviceid'); continue; } final userDeviceKeyIds = deviceKeyIds[device.userId] ??= {}; userDeviceKeyIds[deviceId] = !device.encryptToDevice; } return deviceKeyIds; } /// clear all cached inbound group sessions. useful for testing void clearOutboundGroupSessions() { _outboundGroupSessions.clear(); } /// Clears the existing outboundGroupSession but first checks if the participating /// devices have been changed. Returns false if the session has not been cleared because /// it wasn't necessary. Otherwise returns true. Future clearOrUseOutboundGroupSession(String roomId, {bool wipe = false, bool use = true}) async { final room = client.getRoomById(roomId); final sess = getOutboundGroupSession(roomId); if (room == null || sess == null || sess.outboundGroupSession == null) { return true; } if (!wipe) { // first check if it needs to be rotated final encryptionContent = room.getState(EventTypes.Encryption)?.parsedRoomEncryptionContent; final maxMessages = encryptionContent?.rotationPeriodMsgs ?? 100; final maxAge = encryptionContent?.rotationPeriodMs ?? 604800000; // default of one week if ((sess.sentMessages ?? maxMessages) >= maxMessages || sess.creationTime .add(Duration(milliseconds: maxAge)) .isBefore(DateTime.now())) { wipe = true; } } final inboundSess = await loadInboundGroupSession(room.id, sess.outboundGroupSession!.session_id(), encryption.identityKey!); if (inboundSess == null) { wipe = true; } if (!wipe) { // next check if the devices in the room changed final devicesToReceive = []; final newDeviceKeys = await room.getUserDeviceKeys(); final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys); // first check for user differences final oldUserIds = Set.from(sess.devices.keys); final newUserIds = Set.from(newDeviceKeyIds.keys); if (oldUserIds.difference(newUserIds).isNotEmpty) { // a user left the room, we must wipe the session wipe = true; } else { final newUsers = newUserIds.difference(oldUserIds); if (newUsers.isNotEmpty) { // new user! Gotta send the megolm session to them devicesToReceive .addAll(newDeviceKeys.where((d) => newUsers.contains(d.userId))); } // okay, now we must test all the individual user devices, if anything new got blocked // or if we need to send to any new devices. // for this it is enough if we iterate over the old user Ids, as the new ones already have the needed keys in the list. // we also know that all the old user IDs appear in the old one, else we have already wiped the session for (final userId in oldUserIds) { final oldBlockedDevices = sess.devices.containsKey(userId) ? Set.from(sess.devices[userId]!.entries .where((e) => e.value) .map((e) => e.key)) : {}; final newBlockedDevices = newDeviceKeyIds.containsKey(userId) ? Set.from(newDeviceKeyIds[userId]! .entries .where((e) => e.value) .map((e) => e.key)) : {}; // we don't really care about old devices that got dropped (deleted), we only care if new ones got added and if new ones got blocked // check if new devices got blocked if (newBlockedDevices.difference(oldBlockedDevices).isNotEmpty) { wipe = true; break; } // and now add all the new devices! final oldDeviceIds = sess.devices.containsKey(userId) ? Set.from(sess.devices[userId]!.entries .where((e) => !e.value) .map((e) => e.key)) : {}; final newDeviceIds = newDeviceKeyIds.containsKey(userId) ? Set.from(newDeviceKeyIds[userId]! .entries .where((e) => !e.value) .map((e) => e.key)) : {}; final newDevices = newDeviceIds.difference(oldDeviceIds); if (newDeviceIds.isNotEmpty) { devicesToReceive.addAll(newDeviceKeys.where( (d) => d.userId == userId && newDevices.contains(d.deviceId))); } } } if (!wipe) { if (!use) { return false; } // okay, we use the outbound group session! sess.devices = newDeviceKeyIds; final rawSession = { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'room_id': room.id, 'session_id': sess.outboundGroupSession!.session_id(), 'session_key': sess.outboundGroupSession!.session_key(), }; try { devicesToReceive.removeWhere((k) => !k.encryptToDevice); if (devicesToReceive.isNotEmpty) { // update allowedAtIndex for (final device in devicesToReceive) { inboundSess!.allowedAtIndex[device.userId] ??= {}; if (!inboundSess.allowedAtIndex[device.userId]! .containsKey(device.curve25519Key) || inboundSess.allowedAtIndex[device.userId]![ device.curve25519Key]! > sess.outboundGroupSession!.message_index()) { inboundSess .allowedAtIndex[device.userId]![device.curve25519Key!] = sess.outboundGroupSession!.message_index(); } } await client.database?.updateInboundGroupSessionAllowedAtIndex( json.encode(inboundSess!.allowedAtIndex), room.id, sess.outboundGroupSession!.session_id()); // send out the key await client.sendToDeviceEncryptedChunked( devicesToReceive, EventTypes.RoomKey, rawSession); } } catch (e, s) { Logs().e( '[LibOlm] Unable to re-send the session key at later index to new devices', e, s); } return false; } } sess.dispose(); _outboundGroupSessions.remove(roomId); await client.database?.removeOutboundGroupSession(roomId); return true; } /// Store an outbound group session in the database Future storeOutboundGroupSession( String roomId, OutboundGroupSession sess) async { final userID = client.userID; if (userID == null) return; await client.database?.storeOutboundGroupSession( roomId, sess.outboundGroupSession!.pickle(userID), json.encode(sess.devices), sess.creationTime.millisecondsSinceEpoch); } final Map> _pendingNewOutboundGroupSessions = {}; /// Creates an outbound group session for a given room id Future createOutboundGroupSession(String roomId) async { final sess = _pendingNewOutboundGroupSessions[roomId]; if (sess != null) { return sess; } _pendingNewOutboundGroupSessions[roomId] = _createOutboundGroupSession(roomId); await _pendingNewOutboundGroupSessions[roomId]; return _pendingNewOutboundGroupSessions.remove(roomId)!; } /// Prepares an outbound group session for a given room ID. That is, load it from /// the database, cycle it if needed and create it if absent. Future prepareOutboundGroupSession(String roomId) async { if (getOutboundGroupSession(roomId) == null) { await loadOutboundGroupSession(roomId); } await clearOrUseOutboundGroupSession(roomId, use: false); if (getOutboundGroupSession(roomId) == null) { await createOutboundGroupSession(roomId); } } Future _createOutboundGroupSession( String roomId) async { await clearOrUseOutboundGroupSession(roomId, wipe: true); final room = client.getRoomById(roomId); if (room == null) { throw Exception( 'Tried to create a megolm session in a non-existing room ($roomId)!'); } final userID = client.userID; if (userID == null) { throw Exception( 'Tried to create a megolm session without being logged in!'); } final deviceKeys = await room.getUserDeviceKeys(); final deviceKeyIds = _getDeviceKeyIdMap(deviceKeys); deviceKeys.removeWhere((k) => !k.encryptToDevice); final outboundGroupSession = olm.OutboundGroupSession(); try { outboundGroupSession.create(); } catch (e, s) { outboundGroupSession.free(); Logs().e('[LibOlm] Unable to create new outboundGroupSession', e, s); rethrow; } final rawSession = { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'room_id': room.id, 'session_id': outboundGroupSession.session_id(), 'session_key': outboundGroupSession.session_key(), }; final allowedAtIndex = >{}; for (final device in deviceKeys) { if (!device.isValid) { Logs().e('Skipping invalid device'); continue; } allowedAtIndex[device.userId] ??= {}; allowedAtIndex[device.userId]![device.curve25519Key!] = outboundGroupSession.message_index(); } setInboundGroupSession( roomId, rawSession['session_id'], encryption.identityKey!, rawSession, allowedAtIndex: allowedAtIndex); final sess = OutboundGroupSession( devices: deviceKeyIds, creationTime: DateTime.now(), outboundGroupSession: outboundGroupSession, key: userID, ); try { await client.sendToDeviceEncryptedChunked( deviceKeys, EventTypes.RoomKey, rawSession); await storeOutboundGroupSession(roomId, sess); _outboundGroupSessions[roomId] = sess; } catch (e, s) { Logs().e( '[LibOlm] Unable to send the session key to the participating devices', e, s); sess.dispose(); rethrow; } return sess; } /// Get an outbound group session for a room id OutboundGroupSession? getOutboundGroupSession(String roomId) { return _outboundGroupSessions[roomId]; } /// Load an outbound group session from database Future loadOutboundGroupSession(String roomId) async { final database = client.database; final userID = client.userID; if (_loadedOutboundGroupSessions.contains(roomId) || _outboundGroupSessions.containsKey(roomId) || database == null || userID == null) { return; // nothing to do } _loadedOutboundGroupSessions.add(roomId); final sess = await database.getOutboundGroupSession( roomId, userID, ); if (sess == null || !sess.isValid) { return; } _outboundGroupSessions[roomId] = sess; } Future isCached() async { if (!enabled) { return false; } return (await encryption.ssss.getCached(megolmKey)) != null; } GetRoomKeysVersionCurrentResponse? _roomKeysVersionCache; DateTime? _roomKeysVersionCacheDate; Future getRoomKeysBackupInfo( [bool useCache = true]) async { if (_roomKeysVersionCache != null && _roomKeysVersionCacheDate != null && useCache && DateTime.now() .subtract(Duration(minutes: 5)) .isBefore(_roomKeysVersionCacheDate!)) { return _roomKeysVersionCache!; } _roomKeysVersionCache = await client.getRoomKeysVersionCurrent(); _roomKeysVersionCacheDate = DateTime.now(); return _roomKeysVersionCache!; } Future loadFromResponse(RoomKeys keys) async { if (!(await isCached())) { return; } final privateKey = base64decodeUnpadded((await encryption.ssss.getCached(megolmKey))!); final decryption = olm.PkDecryption(); final info = await getRoomKeysBackupInfo(); String backupPubKey; try { backupPubKey = decryption.init_with_private_key(privateKey); if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2 || info.authData['public_key'] != backupPubKey) { return; } for (final roomEntry in keys.rooms.entries) { final roomId = roomEntry.key; for (final sessionEntry in roomEntry.value.sessions.entries) { final sessionId = sessionEntry.key; final session = sessionEntry.value; final sessionData = session.sessionData; Map? decrypted; try { decrypted = json.decode(decryption.decrypt(sessionData['ephemeral'], sessionData['mac'], sessionData['ciphertext'])); } catch (e, s) { Logs().e('[LibOlm] Error decrypting room key', e, s); } if (decrypted != null) { decrypted['session_id'] = sessionId; decrypted['room_id'] = roomId; setInboundGroupSession( roomId, sessionId, decrypted['sender_key'], decrypted, forwarded: true, senderClaimedKeys: decrypted['sender_claimed_keys'] != null ? Map.from( decrypted['sender_claimed_keys']!) : {}, uploaded: true); } } } } finally { decryption.free(); } } Future loadSingleKey(String roomId, String sessionId) async { final info = await getRoomKeysBackupInfo(); final ret = await client.getRoomKeysBySessionId(roomId, sessionId, info.version); final keys = RoomKeys.fromJson({ 'rooms': { roomId: { 'sessions': { sessionId: ret.toJson(), }, }, }, }); await loadFromResponse(keys); } /// Request a certain key from another device Future request( Room room, String sessionId, String senderKey, { bool tryOnlineBackup = true, bool onlineKeyBackupOnly = false, }) async { if (tryOnlineBackup && await isCached()) { // let's first check our online key backup store thingy... final hadPreviously = getInboundGroupSession(room.id, sessionId, senderKey) != null; try { await loadSingleKey(room.id, sessionId); } catch (err, stacktrace) { if (err is MatrixException && err.errcode == 'M_NOT_FOUND') { Logs().i( '[KeyManager] Key not in online key backup, requesting it from other devices...'); } else { Logs().e('[KeyManager] Failed to access online key backup', err, stacktrace); } } // TODO: also don't request from others if we have an index of 0 now if (!hadPreviously && getInboundGroupSession(room.id, sessionId, senderKey) != null) { return; // we managed to load the session from online backup, no need to care about it now } } if (onlineKeyBackupOnly) { return; // we only want to do the online key backup } try { // while we just send the to-device event to '*', we still need to save the // devices themself to know where to send the cancel to after receiving a reply final devices = await room.getUserDeviceKeys(); final requestId = client.generateUniqueTransactionId(); final request = KeyManagerKeyShareRequest( requestId: requestId, devices: devices, room: room, sessionId: sessionId, senderKey: senderKey, ); final userList = await room.requestParticipants(); await client.sendToDevicesOfUserIds( userList.map((u) => u.id).toSet(), EventTypes.RoomKeyRequest, { 'action': 'request', 'body': { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'room_id': room.id, 'sender_key': senderKey, 'session_id': sessionId, }, 'request_id': requestId, 'requesting_device_id': client.deviceID, }, ); outgoingShareRequests[request.requestId] = request; } catch (e, s) { Logs().e('[Key Manager] Sending key verification request failed', e, s); } } bool _isUploadingKeys = false; bool _haveKeysToUpload = true; Future backgroundTasks() async { final database = client.database; final userID = client.userID; if (_isUploadingKeys || database == null || userID == null) { return; } _isUploadingKeys = true; try { if (!_haveKeysToUpload || !(await isCached())) { return; // we can't backup anyways } final dbSessions = await database.getInboundGroupSessionsToUpload(); if (dbSessions.isEmpty) { _haveKeysToUpload = false; return; // nothing to do } final privateKey = base64decodeUnpadded((await encryption.ssss.getCached(megolmKey))!); // decryption is needed to calculate the public key and thus see if the claimed information is in fact valid final decryption = olm.PkDecryption(); final info = await getRoomKeysBackupInfo(false); String backupPubKey; try { backupPubKey = decryption.init_with_private_key(privateKey); if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2 || info.authData['public_key'] != backupPubKey) { return; } final args = _GenerateUploadKeysArgs( pubkey: backupPubKey, dbSessions: <_DbInboundGroupSessionBundle>[], userId: userID, ); // we need to calculate verified beforehand, as else we pass a closure to an isolate // with 500 keys they do, however, noticably block the UI, which is why we give brief async suspentions in here // so that the event loop can progress var i = 0; for (final dbSession in dbSessions) { final device = client.getUserDeviceKeysByCurve25519Key(dbSession.senderKey); args.dbSessions.add(_DbInboundGroupSessionBundle( dbSession: dbSession, verified: device?.verified ?? false, )); i++; if (i > 10) { await Future.delayed(Duration(milliseconds: 1)); i = 0; } } final roomKeys = await client.runInBackground( _generateUploadKeys, args); Logs().i('[Key Manager] Uploading ${dbSessions.length} room keys...'); // upload the payload... await client.putRoomKeys(info.version, roomKeys); // and now finally mark all the keys as uploaded // no need to optimze this, as we only run it so seldomly and almost never with many keys at once for (final dbSession in dbSessions) { await database.markInboundGroupSessionAsUploaded( dbSession.roomId, dbSession.sessionId); } } finally { decryption.free(); } } catch (e, s) { Logs().e('[Key Manager] Error uploading room keys', e, s); } finally { _isUploadingKeys = false; } } /// Handle an incoming to_device event that is related to key sharing Future handleToDeviceEvent(ToDeviceEvent event) async { if (event.type == EventTypes.RoomKeyRequest) { if (!(event.content['request_id'] is String)) { return; // invalid event } if (event.content['action'] == 'request') { // we are *receiving* a request Logs().i( '[KeyManager] Received key sharing request from ${event.sender}:${event.content['requesting_device_id']}...'); if (!event.content.containsKey('body')) { Logs().i('[KeyManager] No body, doing nothing'); return; // no body } final device = client.userDeviceKeys[event.sender] ?.deviceKeys[event.content['requesting_device_id']]; if (device == null) { Logs().i('[KeyManager] Device not found, doing nothing'); return; // device not found } if (device.userId == client.userID && device.deviceId == client.deviceID) { Logs().i('[KeyManager] Request is by ourself, ignoring'); return; // ignore requests by ourself } final room = client.getRoomById(event.content['body']['room_id']); if (room == null) { Logs().i('[KeyManager] Unknown room, ignoring'); return; // unknown room } final sessionId = event.content['body']['session_id']; final senderKey = event.content['body']['sender_key']; // okay, let's see if we have this session at all final session = await loadInboundGroupSession(room.id, sessionId, senderKey); if (session == null) { Logs().i('[KeyManager] Unknown session, ignoring'); return; // we don't have this session anyways } final request = KeyManagerKeyShareRequest( requestId: event.content['request_id'], devices: [device], room: room, sessionId: sessionId, senderKey: senderKey, ); if (incomingShareRequests.containsKey(request.requestId)) { Logs().i('[KeyManager] Already processed this request, ignoring'); return; // we don't want to process one and the same request multiple times } incomingShareRequests[request.requestId] = request; final roomKeyRequest = RoomKeyRequest.fromToDeviceEvent(event, this, request); if (device.userId == client.userID && device.verified && !device.blocked) { Logs().i('[KeyManager] All checks out, forwarding key...'); // alright, we can forward the key await roomKeyRequest.forwardKey(); } else if (device.encryptToDevice && session.allowedAtIndex .tryGet>(device.userId) ?.tryGet(device.curve25519Key!) != null) { // if we know the user may see the message, then we can just forward the key. // we do not need to check if the device is verified, just if it is not blocked, // as that is the logic we already initially try to send out the room keys. final index = session.allowedAtIndex[device.userId]![device.curve25519Key]!; Logs().i( '[KeyManager] Valid foreign request, forwarding key at index $index...'); await roomKeyRequest.forwardKey(index); } else { Logs() .i('[KeyManager] Asking client, if the key should be forwarded'); client.onRoomKeyRequest .add(roomKeyRequest); // let the client handle this } } else if (event.content['action'] == 'request_cancellation') { // we got told to cancel an incoming request if (!incomingShareRequests.containsKey(event.content['request_id'])) { return; // we don't know this request anyways } // alright, let's just cancel this request final request = incomingShareRequests[event.content['request_id']]!; request.canceled = true; incomingShareRequests.remove(request.requestId); } } else if (event.type == EventTypes.ForwardedRoomKey) { // we *received* an incoming key request final encryptedContent = event.encryptedContent; if (encryptedContent == null) { return; // event wasn't encrypted, this is a security risk } final request = outgoingShareRequests.values.firstWhereOrNull((r) => r.room.id == event.content['room_id'] && r.sessionId == event.content['session_id'] && r.senderKey == event.content['sender_key']); if (request == null || request.canceled) { return; // no associated request found or it got canceled } final device = request.devices.firstWhereOrNull((d) => d.userId == event.sender && d.curve25519Key == encryptedContent['sender_key']); if (device == null) { return; // someone we didn't send our request to replied....better ignore this } // we add the sender key to the forwarded key chain if (!(event.content['forwarding_curve25519_key_chain'] is List)) { event.content['forwarding_curve25519_key_chain'] = []; } event.content['forwarding_curve25519_key_chain'] .add(encryptedContent['sender_key']); // TODO: verify that the keys work to decrypt a message // alright, all checks out, let's go ahead and store this session setInboundGroupSession( request.room.id, request.sessionId, request.senderKey, event.content, forwarded: true, senderClaimedKeys: { 'ed25519': event.content['sender_claimed_ed25519_key'], }); request.devices.removeWhere( (k) => k.userId == device.userId && k.deviceId == device.deviceId); outgoingShareRequests.remove(request.requestId); // send cancel to all other devices if (request.devices.isEmpty) { return; // no need to send any cancellation } // Send with send-to-device messaging final sendToDeviceMessage = { 'action': 'request_cancellation', 'request_id': request.requestId, 'requesting_device_id': client.deviceID, }; final data = >>{}; for (final device in request.devices) { final userData = data[device.userId] ??= {}; userData[device.deviceId!] = sendToDeviceMessage; } await client.sendToDevice( EventTypes.RoomKeyRequest, client.generateUniqueTransactionId(), data, ); } else if (event.type == EventTypes.RoomKey) { Logs().v( '[KeyManager] Received room key with session ${event.content['session_id']}'); final encryptedContent = event.encryptedContent; if (encryptedContent == null) { Logs().v('[KeyManager] not encrypted, ignoring...'); return; // the event wasn't encrypted, this is a security risk; } final String roomId = event.content['room_id']; final String sessionId = event.content['session_id']; final sender_ed25519 = client.userDeviceKeys[event.sender] ?.deviceKeys[event.content['requesting_device_id']]?.ed25519Key; if (sender_ed25519 != null) { event.content['sender_claimed_ed25519_key'] = sender_ed25519; } Logs().v('[KeyManager] Keeping room key'); setInboundGroupSession( roomId, sessionId, encryptedContent['sender_key'], event.content, forwarded: false); } } void dispose() { for (final sess in _outboundGroupSessions.values) { sess.dispose(); } for (final entries in _inboundGroupSessions.values) { for (final sess in entries.values) { sess.dispose(); } } } } class KeyManagerKeyShareRequest { final String requestId; final List devices; final Room room; final String sessionId; final String senderKey; bool canceled; KeyManagerKeyShareRequest( {required this.requestId, List? devices, required this.room, required this.sessionId, required this.senderKey, this.canceled = false}) : devices = devices ?? []; } class RoomKeyRequest extends ToDeviceEvent { KeyManager keyManager; KeyManagerKeyShareRequest request; RoomKeyRequest.fromToDeviceEvent( ToDeviceEvent toDeviceEvent, this.keyManager, this.request) : super( sender: toDeviceEvent.sender, content: toDeviceEvent.content, type: toDeviceEvent.type); Room get room => request.room; DeviceKeys get requestingDevice => request.devices.first; Future forwardKey([int? index]) async { if (request.canceled) { keyManager.incomingShareRequests.remove(request.requestId); return; // request is canceled, don't send anything } final room = this.room; final session = await keyManager.loadInboundGroupSession( room.id, request.sessionId, request.senderKey); if (session?.inboundGroupSession == null) { Logs().v("[KeyManager] Not forwarding key we don't have"); return; } final message = session!.content.copy(); message['forwarding_curve25519_key_chain'] = List.from(session.forwardingCurve25519KeyChain); message['sender_key'] = (session.senderKey.isNotEmpty) ? session.senderKey : request.senderKey; message['sender_claimed_ed25519_key'] = session.senderClaimedKeys['ed25519'] ?? (session.forwardingCurve25519KeyChain.isEmpty ? keyManager.encryption.fingerprintKey : null); message['session_key'] = session.inboundGroupSession!.export_session( index ?? session.inboundGroupSession!.first_known_index()); // send the actual reply of the key back to the requester await keyManager.client.sendToDeviceEncrypted( [requestingDevice], EventTypes.ForwardedRoomKey, message, ); keyManager.incomingShareRequests.remove(request.requestId); } } RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) { final enc = olm.PkEncryption(); try { enc.set_recipient_key(args.pubkey); // first we generate the payload to upload all the session keys in this chunk final roomKeys = RoomKeys(rooms: {}); for (final dbSession in args.dbSessions) { final sess = SessionKey.fromDb(dbSession.dbSession, args.userId); if (!sess.isValid) { continue; } // create the room if it doesn't exist final roomKeyBackup = roomKeys.rooms[sess.roomId] ??= RoomKeyBackup(sessions: {}); // generate the encrypted content final payload = { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'forwarding_curve25519_key_chain': sess.forwardingCurve25519KeyChain, 'sender_key': sess.senderKey, 'sender_clencaimed_keys': sess.senderClaimedKeys, 'session_key': sess.inboundGroupSession! .export_session(sess.inboundGroupSession!.first_known_index()), }; // encrypt the content final encrypted = enc.encrypt(json.encode(payload)); // fetch the device, if available... //final device = args.client.getUserDeviceKeysByCurve25519Key(sess.senderKey); // aaaand finally add the session key to our payload roomKeyBackup.sessions[sess.sessionId] = KeyBackupData( firstMessageIndex: sess.inboundGroupSession!.first_known_index(), forwardedCount: sess.forwardingCurve25519KeyChain.length, isVerified: dbSession.verified, //device?.verified ?? false, sessionData: { 'ephemeral': encrypted.ephemeral, 'ciphertext': encrypted.ciphertext, 'mac': encrypted.mac, }, ); } return roomKeys; } catch (e, s) { Logs().e('[Key Manager] Error generating payload', e, s); rethrow; } finally { enc.free(); } } class _DbInboundGroupSessionBundle { _DbInboundGroupSessionBundle( {required this.dbSession, required this.verified}); StoredInboundGroupSession dbSession; bool verified; } class _GenerateUploadKeysArgs { _GenerateUploadKeysArgs( {required this.pubkey, required this.dbSessions, required this.userId}); String pubkey; List<_DbInboundGroupSessionBundle> dbSessions; String userId; }