/* * 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:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:vodozemac/vodozemac.dart' as vod; import 'package:matrix/encryption/encryption.dart'; import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/encryption/utils/outbound_group_session.dart'; import 'package:matrix/encryption/utils/pickle_key.dart'; import 'package:matrix/encryption/utils/session_key.dart'; import 'package:matrix/encryption/utils/stored_inbound_group_session.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/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 { try { final keyObj = vod.PkDecryption.fromSecretKey( vod.Curve25519PublicKey.fromBase64(secret), ); final info = await getRoomKeysBackupInfo(false); if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2) { return false; } return keyObj.publicKey == info.authData['public_key']; } catch (_) { return false; } }); 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) { final sessionId = lastEvent.content.tryGet('session_id'); final senderKey = lastEvent.content.tryGet('sender_key'); if (sessionId != null && senderKey != null) { maybeAutoRequest( room.id, sessionId, senderKey, ); } } } }); } bool get enabled => encryption.ssss.isSecret(megolmKey); /// clear all cached inbound group sessions. useful for testing void clearInboundGroupSessions() { _inboundGroupSessions.clear(); } Future setInboundGroupSession( String roomId, String sessionId, String senderKey, Map content, { bool forwarded = false, Map? senderClaimedKeys, bool uploaded = false, Map>? allowedAtIndex, }) async { final senderClaimedKeys_ = senderClaimedKeys ?? {}; final allowedAtIndex_ = allowedAtIndex ?? >{}; final userId = client.userID; if (userId == null) return Future.value(); if (!senderClaimedKeys_.containsKey('ed25519')) { final device = client.getUserDeviceKeysByCurve25519Key(senderKey); if (device != null && device.ed25519Key != null) { senderClaimedKeys_['ed25519'] = device.ed25519Key!; } } final oldSession = getInboundGroupSession( roomId, sessionId, ); if (content['algorithm'] != AlgorithmTypes.megolmV1AesSha2) { return; } late vod.InboundGroupSession inboundGroupSession; try { if (forwarded) { inboundGroupSession = vod.InboundGroupSession.import(content['session_key']); } else { inboundGroupSession = vod.InboundGroupSession(content['session_key']); } } catch (e, s) { Logs().e('[Vodozemac] Could not create new InboundGroupSession', e, s); return Future.value(); } final newSession = SessionKey( content: content, inboundGroupSession: inboundGroupSession, indexes: {}, roomId: roomId, sessionId: sessionId, key: userId, senderKey: senderKey, senderClaimedKeys: senderClaimedKeys_, allowedAtIndex: allowedAtIndex_, ); final oldFirstIndex = oldSession?.inboundGroupSession?.firstKnownIndex ?? 0; final newFirstIndex = newSession.inboundGroupSession!.firstKnownIndex; if (oldSession == null || newFirstIndex < oldFirstIndex || (oldFirstIndex == newFirstIndex && newSession.forwardingCurve25519KeyChain.length < oldSession.forwardingCurve25519KeyChain.length)) { // use new session } else { // we are gonna keep our old session return; } final roomInboundGroupSessions = _inboundGroupSessions[roomId] ??= {}; roomInboundGroupSessions[sessionId] = newSession; if (!client.isLogged() || client.encryption == null) { return; } final storeFuture = client.database .storeInboundGroupSession( roomId, sessionId, inboundGroupSession.toPickleEncrypted(userId.toPickleKey()), json.encode(content), json.encode({}), json.encode(allowedAtIndex_), senderKey, json.encode(senderClaimedKeys_), ) .then((_) async { if (!client.isLogged() || client.encryption == null) { return; } if (uploaded) { await client.database .markInboundGroupSessionAsUploaded(roomId, sessionId); } }); final room = client.getRoomById(roomId); if (room != null) { // attempt to decrypt the last event final event = room.lastEvent; if (event != null && event.type == EventTypes.Encrypted && event.content['session_id'] == sessionId) { final decrypted = encryption.decryptRoomEventSync(event); if (decrypted.type != EventTypes.Encrypted) { // Update the last event in memory first room.lastEvent = decrypted; // To persist it in database and trigger UI updates: await client.database.transaction(() async { await client.handleSync( SyncUpdate( nextBatch: '', rooms: switch (room.membership) { Membership.join => RoomsUpdate(join: {room.id: JoinedRoomUpdate()}), Membership.ban || Membership.leave => RoomsUpdate(leave: {room.id: LeftRoomUpdate()}), Membership.invite => RoomsUpdate(invite: {room.id: InvitedRoomUpdate()}), Membership.knock => RoomsUpdate(knock: {room.id: KnockRoomUpdate()}), }, ), ); }); } } // and finally broadcast the new session room.onSessionKeyReceived.add(sessionId); } return storeFuture; } SessionKey? getInboundGroupSession(String roomId, String sessionId) { final sess = _inboundGroupSessions[roomId]?[sessionId]; if (sess != null) { if (sess.sessionId != sessionId && sess.sessionId.isNotEmpty) { return null; } return sess; } return null; } /// Attempt auto-request for a key void maybeAutoRequest( String roomId, String sessionId, String? senderKey, { bool tryOnlineBackup = true, bool onlineKeyBackupOnly = true, }) { final room = client.getRoomById(roomId); final requestIdent = '$roomId|$sessionId'; if (room != null && !_requestedSessionIds.contains(requestIdent) && !client.isUnknownSession) { // do e2ee recovery _requestedSessionIds.add(requestIdent); runInRoot( () async => request( room, sessionId, senderKey, tryOnlineBackup: tryOnlineBackup, onlineKeyBackupOnly: onlineKeyBackupOnly, ), ); } } /// Loads an inbound group session Future loadInboundGroupSession( String roomId, String sessionId, ) async { final sess = _inboundGroupSessions[roomId]?[sessionId]; if (sess != null) { if (sess.sessionId != sessionId && sess.sessionId.isNotEmpty) { return null; // session_id does 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.sessionId.isEmpty || dbSess.sessionId != sessionId) { return null; } return roomInboundGroupSessions[sessionId] = dbSess; } 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!.sessionId, ); if (inboundSess == null) { Logs().w('No inbound megolm session found for outbound session!'); assert(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 = sess.devices.keys.toSet(); final newUserIds = newDeviceKeyIds.keys.toSet(); 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) ? sess.devices[userId]!.entries .where((e) => e.value) .map((e) => e.key) .toSet() : {}; final newBlockedDevices = newDeviceKeyIds.containsKey(userId) ? newDeviceKeyIds[userId]! .entries .where((e) => e.value) .map((e) => e.key) .toSet() : {}; // 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) ? sess.devices[userId]!.entries .where((e) => !e.value) .map((e) => e.key) .toSet() : {}; final newDeviceIds = newDeviceKeyIds.containsKey(userId) ? newDeviceKeyIds[userId]! .entries .where((e) => !e.value) .map((e) => e.key) .toSet() : {}; // check if a device got removed if (oldDeviceIds.difference(newDeviceIds).isNotEmpty) { wipe = true; break; } // check if any new devices need keys 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!.sessionId, 'session_key': sess.outboundGroupSession!.sessionKey, }; 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!.messageIndex) { inboundSess .allowedAtIndex[device.userId]![device.curve25519Key!] = sess.outboundGroupSession!.messageIndex; } } await client.database.updateInboundGroupSessionAllowedAtIndex( json.encode(inboundSess!.allowedAtIndex), room.id, sess.outboundGroupSession!.sessionId, ); // send out the key await client.sendToDeviceEncryptedChunked( devicesToReceive, EventTypes.RoomKey, rawSession, ); } } catch (e, s) { Logs().e( '[Vodozemac] Unable to re-send the session key at later index to new devices', e, s, ); } return false; } } _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!.toPickleEncrypted(userID.toPickleKey()), 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; } final newSess = _pendingNewOutboundGroupSessions[roomId] = _createOutboundGroupSession(roomId); try { await newSess; } finally { _pendingNewOutboundGroupSessions .removeWhere((_, value) => value == newSess); } return newSess; } /// 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); await client.firstSyncReceived; 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 = vod.GroupSession(); final rawSession = { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'room_id': room.id, 'session_id': outboundGroupSession.sessionId, 'session_key': outboundGroupSession.sessionKey, }; 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.messageIndex; } await 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( '[Vodozemac] Unable to send the session key to the participating devices', e, s, ); 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) || 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 { await client.accountDataLoading; if (!enabled) { return false; } await client.userDeviceKeysLoading; 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 info = await getRoomKeysBackupInfo(); String backupPubKey; final decryption = vod.PkDecryption.fromSecretKey( vod.Curve25519PublicKey.fromBytes(privateKey), ); backupPubKey = decryption.publicKey; 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( vod.PkMessage.fromBase64( ciphertext: sessionData['ciphertext'] as String, mac: sessionData['mac'] as String, ephemeralKey: sessionData['ephemeral'] as String, ), ), ); } catch (e, s) { Logs().e('[Vodozemac] Error decrypting room key', e, s); } final senderKey = decrypted?.tryGet('sender_key'); if (decrypted != null && senderKey != null) { decrypted['session_id'] = sessionId; decrypted['room_id'] = roomId; await setInboundGroupSession( roomId, sessionId, senderKey, decrypted, forwarded: true, senderClaimedKeys: decrypted.tryGetMap('sender_claimed_keys') ?? {}, uploaded: true, ); } } } } /// Loads and stores all keys from the online key backup. This may take a /// while for older and big accounts. Future loadAllKeys() async { final info = await getRoomKeysBackupInfo(); final ret = await client.getRoomKeys(info.version); await loadFromResponse(ret); } /// Loads all room keys for a single room and stores them. This may take a /// while for older and big rooms. Future loadAllKeysFromRoom(String roomId) async { final info = await getRoomKeysBackupInfo(); final ret = await client.getRoomKeysByRoomId(roomId, info.version); final keys = RoomKeys.fromJson({ 'rooms': { roomId: { 'sessions': ret.sessions.map((k, s) => MapEntry(k, s.toJson())), }, }, }); await loadFromResponse(keys); } /// Loads a single key for the specified room from the online key backup /// and stores it. Future loadSingleKey(String roomId, String sessionId) async { final info = await getRoomKeysBackupInfo(); final ret = await client.getRoomKeyBySessionId(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) != 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) != 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, ); 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, 'session_id': sessionId, if (senderKey != null) 'sender_key': senderKey, }, '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); } } Future? _uploadingFuture; void startAutoUploadKeys() { _uploadKeysOnSync = encryption.client.onSync.stream.listen( (_) async => uploadInboundGroupSessions(skipIfInProgress: true), ); } /// This task should be performed after sync processing but should not block /// the sync. To make sure that it never gets executed multiple times, it is /// skipped when an upload task is already in progress. Set `skipIfInProgress` /// to `false` to await the pending upload task instead. Future uploadInboundGroupSessions({ bool skipIfInProgress = false, }) async { final database = client.database; final userID = client.userID; if (userID == null) { return; } // Make sure to not run in parallel if (_uploadingFuture != null) { if (skipIfInProgress) return; try { await _uploadingFuture; } finally { // shouldn't be necessary, since it will be unset already by the other process that started it, but just to be safe, also unset the future here _uploadingFuture = null; } } Future uploadInternal() async { try { await client.userDeviceKeysLoading; if (!(await isCached())) { return; // we can't backup anyways } final dbSessions = await database.getInboundGroupSessionsToUpload(); if (dbSessions.isEmpty) { 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 info = await getRoomKeysBackupInfo(false); String backupPubKey; final decryption = vod.PkDecryption.fromSecretKey( vod.Curve25519PublicKey.fromBytes(privateKey), ); backupPubKey = decryption.publicKey; if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2 || info.authData['public_key'] != backupPubKey) { return; } final args = GenerateUploadKeysArgs( pubkey: backupPubKey, dbSessions: [], 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.nativeImplementations.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, ); } } catch (e, s) { Logs().e('[Key Manager] Error uploading room keys', e, s); } } _uploadingFuture = uploadInternal(); try { await _uploadingFuture; } finally { _uploadingFuture = null; } } /// 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().w('[KeyManager] No body, doing nothing'); return; // no body } final body = event.content.tryGetMap('body'); if (body == null) { Logs().w('[KeyManager] Wrong type for body, doing nothing'); return; // wrong type for body } final roomId = body.tryGet('room_id'); if (roomId == null) { Logs().w( '[KeyManager] Wrong type for room_id or no room_id, doing nothing', ); return; // wrong type for roomId or no roomId found } final device = client.userDeviceKeys[event.sender] ?.deviceKeys[event.content['requesting_device_id']]; if (device == null) { Logs().w('[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(roomId); if (room == null) { Logs().i('[KeyManager] Unknown room, ignoring'); return; // unknown room } final sessionId = body.tryGet('session_id'); if (sessionId == null) { Logs().w( '[KeyManager] Wrong type for session_id or no session_id, doing nothing', ); return; // wrong type for session_id } // okay, let's see if we have this session at all final session = await loadInboundGroupSession(room.id, sessionId); if (session == null) { Logs().i('[KeyManager] Unknown session, ignoring'); return; // we don't have this session anyways } if (event.content['request_id'] is! String) { Logs().w( '[KeyManager] Wrong type for request_id or no request_id, doing nothing', ); return; // wrong type for request_id } final request = KeyManagerKeyShareRequest( requestId: event.content.tryGet('request_id')!, devices: [device], room: room, sessionId: sessionId, ); 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) { Logs().w( 'Ignoring an unencrypted forwarded key from a to device message', event.toJson(), ); return; } final request = outgoingShareRequests.values.firstWhereOrNull( (r) => r.room.id == event.content['room_id'] && r.sessionId == event.content['session_id'], ); 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'] as List) .add(encryptedContent['sender_key']); if (event.content['sender_claimed_ed25519_key'] is! String) { Logs().w('sender_claimed_ed255519_key has wrong type'); return; // wrong type } // TODO: verify that the keys work to decrypt a message // alright, all checks out, let's go ahead and store this session await setInboundGroupSession( request.room.id, request.sessionId, device.curve25519Key!, event.content, forwarded: true, senderClaimedKeys: { 'ed25519': event.content['sender_claimed_ed25519_key'] as String, }, ); 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 roomId = event.content.tryGet('room_id'); final sessionId = event.content.tryGet('session_id'); if (roomId == null || sessionId == null) { Logs().w( 'Either room_id or session_id are not the expected type or missing', ); return; } 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'); await setInboundGroupSession( roomId, sessionId, encryptedContent['sender_key'], event.content, forwarded: false, ); } } StreamSubscription? _uploadKeysOnSync; void dispose() { // ignore: discarded_futures _uploadKeysOnSync?.cancel(); } } class KeyManagerKeyShareRequest { final String requestId; final List devices; final Room room; final String sessionId; bool canceled; KeyManagerKeyShareRequest({ required this.requestId, List? devices, required this.room, required this.sessionId, 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); 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); if (session.senderKey.isNotEmpty) { message['sender_key'] = session.senderKey; } message['sender_claimed_ed25519_key'] = session.senderClaimedKeys['ed25519'] ?? (session.forwardingCurve25519KeyChain.isEmpty ? keyManager.encryption.fingerprintKey : null); message['session_key'] = session.inboundGroupSession!.exportAt( index ?? session.inboundGroupSession!.firstKnownIndex, ); // send the actual reply of the key back to the requester await keyManager.client.sendToDeviceEncrypted( [requestingDevice], EventTypes.ForwardedRoomKey, message, ); keyManager.incomingShareRequests.remove(request.requestId); } } /// you would likely want to use [NativeImplementations] and /// [Client.nativeImplementations] instead RoomKeys generateUploadKeysImplementation(GenerateUploadKeysArgs args) { try { final enc = vod.PkEncryption.fromPublicKey( vod.Curve25519PublicKey.fromBase64(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_claimed_keys': sess.senderClaimedKeys, 'session_key': sess.inboundGroupSession!.exportAtFirstKnownIndex(), }; // 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 final (ciphertext, mac, ephemeral) = encrypted.toBase64(); roomKeyBackup.sessions[sess.sessionId] = KeyBackupData( firstMessageIndex: sess.inboundGroupSession!.firstKnownIndex, forwardedCount: sess.forwardingCurve25519KeyChain.length, isVerified: dbSession.verified, //device?.verified ?? false, sessionData: { 'ephemeral': ephemeral, 'ciphertext': ciphertext, 'mac': mac, }, ); } return roomKeys; } catch (e, s) { Logs().e('[Key Manager] Error generating payload', e, s); rethrow; } } class DbInboundGroupSessionBundle { DbInboundGroupSessionBundle({ required this.dbSession, required this.verified, }); factory DbInboundGroupSessionBundle.fromJson(Map json) => DbInboundGroupSessionBundle( dbSession: StoredInboundGroupSession.fromJson(Map.from(json['dbSession'])), verified: json['verified'], ); Map toJson() => { 'dbSession': dbSession.toJson(), 'verified': verified, }; StoredInboundGroupSession dbSession; bool verified; } class GenerateUploadKeysArgs { GenerateUploadKeysArgs({ required this.pubkey, required this.dbSessions, required this.userId, }); factory GenerateUploadKeysArgs.fromJson(Map json) => GenerateUploadKeysArgs( pubkey: json['pubkey'], dbSessions: (json['dbSessions'] as Iterable) .map((e) => DbInboundGroupSessionBundle.fromJson(e)) .toList(), userId: json['userId'], ); Map toJson() => { 'pubkey': pubkey, 'dbSessions': dbSessions.map((e) => e.toJson()).toList(), 'userId': userId, }; String pubkey; List dbSessions; String userId; }