LCOV - code coverage report
Current view: top level - lib/encryption - olm_manager.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 292 352 83.0 %
Date: 2024-09-27 11:38:01 Functions: 0 0 -

          Line data    Source code
       1             : /*
       2             :  *   Famedly Matrix SDK
       3             :  *   Copyright (C) 2019, 2020, 2021 Famedly GmbH
       4             :  *
       5             :  *   This program is free software: you can redistribute it and/or modify
       6             :  *   it under the terms of the GNU Affero General Public License as
       7             :  *   published by the Free Software Foundation, either version 3 of the
       8             :  *   License, or (at your option) any later version.
       9             :  *
      10             :  *   This program is distributed in the hope that it will be useful,
      11             :  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
      12             :  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      13             :  *   GNU Affero General Public License for more details.
      14             :  *
      15             :  *   You should have received a copy of the GNU Affero General Public License
      16             :  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
      17             :  */
      18             : 
      19             : import 'dart:convert';
      20             : 
      21             : import 'package:async/async.dart';
      22             : import 'package:canonical_json/canonical_json.dart';
      23             : import 'package:collection/collection.dart';
      24             : import 'package:olm/olm.dart' as olm;
      25             : 
      26             : import 'package:matrix/encryption/encryption.dart';
      27             : import 'package:matrix/encryption/utils/json_signature_check_extension.dart';
      28             : import 'package:matrix/encryption/utils/olm_session.dart';
      29             : import 'package:matrix/matrix.dart';
      30             : import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/api.dart';
      31             : import 'package:matrix/src/utils/run_in_root.dart';
      32             : 
      33             : class OlmManager {
      34             :   final Encryption encryption;
      35          72 :   Client get client => encryption.client;
      36             :   olm.Account? _olmAccount;
      37             :   String? ourDeviceId;
      38             : 
      39             :   /// Returns the base64 encoded keys to store them in a store.
      40             :   /// This String should **never** leave the device!
      41          23 :   String? get pickledOlmAccount =>
      42         115 :       enabled ? _olmAccount!.pickle(client.userID!) : null;
      43          23 :   String? get fingerprintKey =>
      44         115 :       enabled ? json.decode(_olmAccount!.identity_keys())['ed25519'] : null;
      45          24 :   String? get identityKey =>
      46         120 :       enabled ? json.decode(_olmAccount!.identity_keys())['curve25519'] : null;
      47             : 
      48           0 :   String? pickleOlmAccountWithKey(String key) =>
      49           0 :       enabled ? _olmAccount!.pickle(key) : null;
      50             : 
      51          48 :   bool get enabled => _olmAccount != null;
      52             : 
      53          24 :   OlmManager(this.encryption);
      54             : 
      55             :   /// A map from Curve25519 identity keys to existing olm sessions.
      56          48 :   Map<String, List<OlmSession>> get olmSessions => _olmSessions;
      57             :   final Map<String, List<OlmSession>> _olmSessions = {};
      58             : 
      59             :   // NOTE(Nico): On initial login we pass null to create a new account
      60          24 :   Future<void> init({
      61             :     String? olmAccount,
      62             :     required String? deviceId,
      63             :     String? pickleKey,
      64             :     String? dehydratedDeviceAlgorithm,
      65             :   }) async {
      66          24 :     ourDeviceId = deviceId;
      67             :     if (olmAccount == null) {
      68             :       try {
      69           4 :         await olm.init();
      70           8 :         _olmAccount = olm.Account();
      71           8 :         _olmAccount!.create();
      72           4 :         if (!await uploadKeys(
      73             :           uploadDeviceKeys: true,
      74             :           updateDatabase: false,
      75             :           dehydratedDeviceAlgorithm: dehydratedDeviceAlgorithm,
      76             :           dehydratedDevicePickleKey:
      77             :               dehydratedDeviceAlgorithm != null ? pickleKey : null,
      78             :         )) {
      79             :           throw ('Upload key failed');
      80             :         }
      81             :       } catch (_) {
      82           0 :         _olmAccount?.free();
      83           0 :         _olmAccount = null;
      84             :         rethrow;
      85             :       }
      86             :     } else {
      87             :       try {
      88          23 :         await olm.init();
      89          46 :         _olmAccount = olm.Account();
      90          92 :         _olmAccount!.unpickle(pickleKey ?? client.userID!, olmAccount);
      91             :       } catch (_) {
      92           2 :         _olmAccount?.free();
      93           1 :         _olmAccount = null;
      94             :         rethrow;
      95             :       }
      96             :     }
      97             :   }
      98             : 
      99             :   /// Adds a signature to this json from this olm account and returns the signed
     100             :   /// json.
     101          24 :   Map<String, dynamic> signJson(Map<String, dynamic> payload) {
     102          24 :     if (!enabled) throw ('Encryption is disabled');
     103          24 :     final Map<String, dynamic>? unsigned = payload['unsigned'];
     104          24 :     final Map<String, dynamic>? signatures = payload['signatures'];
     105          24 :     payload.remove('unsigned');
     106          24 :     payload.remove('signatures');
     107          24 :     final canonical = canonicalJson.encode(payload);
     108          72 :     final signature = _olmAccount!.sign(String.fromCharCodes(canonical));
     109             :     if (signatures != null) {
     110           0 :       payload['signatures'] = signatures;
     111             :     } else {
     112          48 :       payload['signatures'] = <String, dynamic>{};
     113             :     }
     114          96 :     if (!payload['signatures'].containsKey(client.userID)) {
     115         120 :       payload['signatures'][client.userID] = <String, dynamic>{};
     116             :     }
     117         168 :     payload['signatures'][client.userID]['ed25519:$ourDeviceId'] = signature;
     118             :     if (unsigned != null) {
     119           0 :       payload['unsigned'] = unsigned;
     120             :     }
     121             :     return payload;
     122             :   }
     123             : 
     124           4 :   String signString(String s) {
     125           8 :     return _olmAccount!.sign(s);
     126             :   }
     127             : 
     128             :   bool _uploadKeysLock = false;
     129             :   CancelableOperation<Map<String, int>>? currentUpload;
     130             : 
     131          42 :   int? get maxNumberOfOneTimeKeys => _olmAccount?.max_number_of_one_time_keys();
     132             : 
     133             :   /// Generates new one time keys, signs everything and upload it to the server.
     134             :   /// If `retry` is > 0, the request will be retried with new OTKs on upload failure.
     135          24 :   Future<bool> uploadKeys({
     136             :     bool uploadDeviceKeys = false,
     137             :     int? oldKeyCount = 0,
     138             :     bool updateDatabase = true,
     139             :     bool? unusedFallbackKey = false,
     140             :     String? dehydratedDeviceAlgorithm,
     141             :     String? dehydratedDevicePickleKey,
     142             :     int retry = 1,
     143             :   }) async {
     144          24 :     final olmAccount = _olmAccount;
     145             :     if (olmAccount == null) {
     146             :       return true;
     147             :     }
     148             : 
     149          24 :     if (_uploadKeysLock) {
     150             :       return false;
     151             :     }
     152          24 :     _uploadKeysLock = true;
     153             : 
     154          24 :     final signedOneTimeKeys = <String, Map<String, Object?>>{};
     155             :     try {
     156             :       int? uploadedOneTimeKeysCount;
     157             :       if (oldKeyCount != null) {
     158             :         // check if we have OTKs that still need uploading. If we do, we don't try to generate new ones,
     159             :         // instead we try to upload the old ones first
     160             :         final oldOTKsNeedingUpload = json
     161          72 :             .decode(olmAccount.one_time_keys())['curve25519']
     162          24 :             .entries
     163          24 :             .length as int;
     164             :         // generate one-time keys
     165             :         // we generate 2/3rds of max, so that other keys people may still have can
     166             :         // still be used
     167             :         final oneTimeKeysCount =
     168         120 :             (olmAccount.max_number_of_one_time_keys() * 2 / 3).floor() -
     169          24 :                 oldKeyCount -
     170             :                 oldOTKsNeedingUpload;
     171          24 :         if (oneTimeKeysCount > 0) {
     172          24 :           olmAccount.generate_one_time_keys(oneTimeKeysCount);
     173             :         }
     174          24 :         uploadedOneTimeKeysCount = oneTimeKeysCount + oldOTKsNeedingUpload;
     175             :       }
     176             : 
     177          72 :       if (encryption.isMinOlmVersion(3, 2, 7) && unusedFallbackKey == false) {
     178             :         // we don't have an unused fallback key uploaded....so let's change that!
     179           5 :         olmAccount.generate_fallback_key();
     180             :       }
     181             : 
     182             :       // we save the generated OTKs into the database.
     183             :       // in case the app gets killed during upload or the upload fails due to bad network
     184             :       // we can still re-try later
     185             :       if (updateDatabase) {
     186          94 :         await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
     187             :       }
     188             : 
     189             :       // and now generate the payload to upload
     190          24 :       var deviceKeys = <String, dynamic>{
     191          48 :         'user_id': client.userID,
     192          24 :         'device_id': ourDeviceId,
     193          24 :         'algorithms': [
     194             :           AlgorithmTypes.olmV1Curve25519AesSha2,
     195             :           AlgorithmTypes.megolmV1AesSha2
     196             :         ],
     197          24 :         'keys': <String, dynamic>{},
     198             :       };
     199             : 
     200             :       if (uploadDeviceKeys) {
     201             :         final Map<String, dynamic> keys =
     202          10 :             json.decode(olmAccount.identity_keys());
     203          10 :         for (final entry in keys.entries) {
     204           5 :           final algorithm = entry.key;
     205           5 :           final value = entry.value;
     206          20 :           deviceKeys['keys']['$algorithm:$ourDeviceId'] = value;
     207             :         }
     208           5 :         deviceKeys = signJson(deviceKeys);
     209             :       }
     210             : 
     211             :       // now sign all the one-time keys
     212             :       for (final entry
     213         120 :           in json.decode(olmAccount.one_time_keys())['curve25519'].entries) {
     214          24 :         final key = entry.key;
     215          24 :         final value = entry.value;
     216          96 :         signedOneTimeKeys['signed_curve25519:$key'] = signJson({
     217             :           'key': value,
     218             :         });
     219             :       }
     220             : 
     221          24 :       final signedFallbackKeys = <String, dynamic>{};
     222          48 :       if (encryption.isMinOlmVersion(3, 2, 7)) {
     223          48 :         final fallbackKey = json.decode(olmAccount.unpublished_fallback_key());
     224             :         // now sign all the fallback keys
     225          53 :         for (final entry in fallbackKey['curve25519'].entries) {
     226           5 :           final key = entry.key;
     227           5 :           final value = entry.value;
     228          20 :           signedFallbackKeys['signed_curve25519:$key'] = signJson({
     229             :             'key': value,
     230             :             'fallback': true,
     231             :           });
     232             :         }
     233             :       }
     234             : 
     235          24 :       if (signedFallbackKeys.isEmpty &&
     236          24 :           signedOneTimeKeys.isEmpty &&
     237             :           !uploadDeviceKeys) {
     238           0 :         _uploadKeysLock = false;
     239             :         return true;
     240             :       }
     241             : 
     242             :       // Workaround: Make sure we stop if we got logged out in the meantime.
     243          48 :       if (!client.isLogged()) return true;
     244             : 
     245          96 :       if (ourDeviceId != client.deviceID) {
     246             :         if (dehydratedDeviceAlgorithm == null ||
     247             :             dehydratedDevicePickleKey == null) {
     248           0 :           throw Exception(
     249             :               'You need to provide both the pickle key and the algorithm to use dehydrated devices!');
     250             :         }
     251             : 
     252           0 :         await client.uploadDehydratedDevice(
     253           0 :           deviceId: ourDeviceId!,
     254             :           initialDeviceDisplayName: 'Dehydrated Device',
     255             :           deviceKeys:
     256           0 :               uploadDeviceKeys ? MatrixDeviceKeys.fromJson(deviceKeys) : null,
     257             :           oneTimeKeys: signedOneTimeKeys,
     258             :           fallbackKeys: signedFallbackKeys,
     259           0 :           deviceData: {
     260             :             'algorithm': dehydratedDeviceAlgorithm,
     261           0 :             'device': encryption.olmManager
     262           0 :                 .pickleOlmAccountWithKey(dehydratedDevicePickleKey),
     263             :           },
     264             :         );
     265             :         return true;
     266             :       }
     267             :       final currentUpload =
     268          96 :           this.currentUpload = CancelableOperation.fromFuture(client.uploadKeys(
     269             :         deviceKeys:
     270           5 :             uploadDeviceKeys ? MatrixDeviceKeys.fromJson(deviceKeys) : null,
     271             :         oneTimeKeys: signedOneTimeKeys,
     272             :         fallbackKeys: signedFallbackKeys,
     273             :       ));
     274          24 :       final response = await currentUpload.valueOrCancellation();
     275             :       if (response == null) {
     276           0 :         _uploadKeysLock = false;
     277             :         return false;
     278             :       }
     279             : 
     280             :       // mark the OTKs as published and save that to datbase
     281          24 :       olmAccount.mark_keys_as_published();
     282             :       if (updateDatabase) {
     283          94 :         await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
     284             :       }
     285             :       return (uploadedOneTimeKeysCount != null &&
     286          48 :               response['signed_curve25519'] == uploadedOneTimeKeysCount) ||
     287             :           uploadedOneTimeKeysCount == null;
     288           0 :     } on MatrixException catch (exception) {
     289           0 :       _uploadKeysLock = false;
     290             : 
     291             :       // we failed to upload the keys. If we only tried to upload one time keys, try to recover by removing them and generating new ones.
     292             :       if (!uploadDeviceKeys &&
     293           0 :           unusedFallbackKey != false &&
     294           0 :           retry > 0 &&
     295             :           dehydratedDeviceAlgorithm != null &&
     296           0 :           signedOneTimeKeys.isNotEmpty &&
     297           0 :           exception.error == MatrixError.M_UNKNOWN) {
     298           0 :         Logs().w('Rotating otks because upload failed', exception);
     299           0 :         for (final otk in signedOneTimeKeys.values) {
     300             :           // Keys can only be removed by creating a session...
     301           0 :           final session = olm.Session();
     302             :           try {
     303             :             final String identity =
     304           0 :                 json.decode(olmAccount.identity_keys())['curve25519'];
     305           0 :             final key = otk.tryGet<String>('key');
     306             :             if (key != null) {
     307           0 :               session.create_outbound(_olmAccount!, identity, key);
     308           0 :               olmAccount.remove_one_time_keys(session);
     309             :             }
     310             :           } finally {
     311           0 :             session.free();
     312             :           }
     313             :         }
     314             : 
     315           0 :         await uploadKeys(
     316             :             uploadDeviceKeys: uploadDeviceKeys,
     317             :             oldKeyCount: oldKeyCount,
     318             :             updateDatabase: updateDatabase,
     319             :             unusedFallbackKey: unusedFallbackKey,
     320           0 :             retry: retry - 1);
     321             :       }
     322             :     } finally {
     323          24 :       _uploadKeysLock = false;
     324             :     }
     325             : 
     326             :     return false;
     327             :   }
     328             : 
     329          24 :   Future<void> handleDeviceOneTimeKeysCount(
     330             :       Map<String, int>? countJson, List<String>? unusedFallbackKeyTypes) async {
     331          24 :     if (!enabled) {
     332             :       return;
     333             :     }
     334          48 :     final haveFallbackKeys = encryption.isMinOlmVersion(3, 2, 0);
     335             :     // Check if there are at least half of max_number_of_one_time_keys left on the server
     336             :     // and generate and upload more if not.
     337             : 
     338             :     // If the server did not send us a count, assume it is 0
     339          24 :     final keyCount = countJson?.tryGet<int>('signed_curve25519') ?? 0;
     340             : 
     341             :     // If the server does not support fallback keys, it will not tell us about them.
     342             :     // If the server supports them but has no key, upload a new one.
     343             :     var unusedFallbackKey = true;
     344          26 :     if (unusedFallbackKeyTypes?.contains('signed_curve25519') == false) {
     345             :       unusedFallbackKey = false;
     346             :     }
     347             : 
     348             :     // fixup accidental too many uploads. We delete only one of them so that the server has time to update the counts and because we will get rate limited anyway.
     349          72 :     if (keyCount > _olmAccount!.max_number_of_one_time_keys()) {
     350           0 :       final requestingKeysFrom = {
     351           0 :         client.userID!: {ourDeviceId!: 'signed_curve25519'}
     352             :       };
     353           0 :       await client.claimKeys(requestingKeysFrom, timeout: 10000);
     354             :     }
     355             : 
     356             :     // Only upload keys if they are less than half of the max or we have no unused fallback key
     357          96 :     if (keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2) ||
     358             :         !unusedFallbackKey) {
     359          24 :       await uploadKeys(
     360          96 :         oldKeyCount: keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2)
     361             :             ? keyCount
     362             :             : null,
     363             :         unusedFallbackKey: haveFallbackKeys ? unusedFallbackKey : null,
     364             :       );
     365             :     }
     366             :   }
     367             : 
     368          23 :   Future<void> storeOlmSession(OlmSession session) async {
     369          46 :     if (session.sessionId == null || session.pickledSession == null) {
     370             :       return;
     371             :     }
     372             : 
     373          92 :     _olmSessions[session.identityKey] ??= <OlmSession>[];
     374          69 :     final ix = _olmSessions[session.identityKey]!
     375          55 :         .indexWhere((s) => s.sessionId == session.sessionId);
     376          46 :     if (ix == -1) {
     377             :       // add a new session
     378          92 :       _olmSessions[session.identityKey]!.add(session);
     379             :     } else {
     380             :       // update an existing session
     381          28 :       _olmSessions[session.identityKey]![ix] = session;
     382             :     }
     383          69 :     await encryption.olmDatabase?.storeOlmSession(
     384          23 :         session.identityKey,
     385          23 :         session.sessionId!,
     386          23 :         session.pickledSession!,
     387          46 :         session.lastReceived?.millisecondsSinceEpoch ??
     388           0 :             DateTime.now().millisecondsSinceEpoch);
     389             :   }
     390             : 
     391          24 :   Future<ToDeviceEvent> _decryptToDeviceEvent(ToDeviceEvent event) async {
     392          48 :     if (event.type != EventTypes.Encrypted) {
     393             :       return event;
     394             :     }
     395          24 :     final content = event.parsedRoomEncryptedContent;
     396          48 :     if (content.algorithm != AlgorithmTypes.olmV1Curve25519AesSha2) {
     397           0 :       throw DecryptException(DecryptException.unknownAlgorithm);
     398             :     }
     399          24 :     if (content.ciphertextOlm == null ||
     400          72 :         !content.ciphertextOlm!.containsKey(identityKey)) {
     401           6 :       throw DecryptException(DecryptException.isntSentForThisDevice);
     402             :     }
     403             :     String? plaintext;
     404          23 :     final senderKey = content.senderKey;
     405          92 :     final body = content.ciphertextOlm![identityKey]!.body;
     406          92 :     final type = content.ciphertextOlm![identityKey]!.type;
     407          23 :     if (type != 0 && type != 1) {
     408           0 :       throw DecryptException(DecryptException.unknownMessageType);
     409             :     }
     410          96 :     final device = client.userDeviceKeys[event.sender]?.deviceKeys.values
     411           8 :         .firstWhereOrNull((d) => d.curve25519Key == senderKey);
     412          46 :     final existingSessions = olmSessions[senderKey];
     413          23 :     Future<void> updateSessionUsage([OlmSession? session]) async {
     414             :       try {
     415             :         if (session != null) {
     416           2 :           session.lastReceived = DateTime.now();
     417           1 :           await storeOlmSession(session);
     418             :         }
     419             :         if (device != null) {
     420           2 :           device.lastActive = DateTime.now();
     421           3 :           await encryption.olmDatabase?.setLastActiveUserDeviceKey(
     422           2 :               device.lastActive.millisecondsSinceEpoch,
     423           1 :               device.userId,
     424           1 :               device.deviceId!);
     425             :         }
     426             :       } catch (e, s) {
     427           0 :         Logs().e('Error while updating olm session timestamp', e, s);
     428             :       }
     429             :     }
     430             : 
     431             :     if (existingSessions != null) {
     432           4 :       for (final session in existingSessions) {
     433           2 :         if (session.session == null) {
     434             :           continue;
     435             :         }
     436           6 :         if (type == 0 && session.session!.matches_inbound(body)) {
     437             :           try {
     438           4 :             plaintext = session.session!.decrypt(type, body);
     439             :           } catch (e) {
     440             :             // The message was encrypted during this session, but is unable to decrypt
     441           1 :             throw DecryptException(
     442           1 :                 DecryptException.decryptionFailed, e.toString());
     443             :           }
     444           1 :           await updateSessionUsage(session);
     445             :           break;
     446           1 :         } else if (type == 1) {
     447             :           try {
     448           0 :             plaintext = session.session!.decrypt(type, body);
     449           0 :             await updateSessionUsage(session);
     450             :             break;
     451             :           } catch (_) {
     452             :             plaintext = null;
     453             :           }
     454             :         }
     455             :       }
     456             :     }
     457          23 :     if (plaintext == null && type != 0) {
     458           0 :       throw DecryptException(DecryptException.unableToDecryptWithAnyOlmSession);
     459             :     }
     460             : 
     461             :     if (plaintext == null) {
     462          23 :       final newSession = olm.Session();
     463             :       try {
     464          46 :         newSession.create_inbound_from(_olmAccount!, senderKey, body);
     465          46 :         _olmAccount!.remove_one_time_keys(newSession);
     466          92 :         await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
     467             : 
     468          23 :         plaintext = newSession.decrypt(type, body);
     469             : 
     470          46 :         await storeOlmSession(OlmSession(
     471          46 :           key: client.userID!,
     472             :           identityKey: senderKey,
     473          23 :           sessionId: newSession.session_id(),
     474             :           session: newSession,
     475          23 :           lastReceived: DateTime.now(),
     476             :         ));
     477          23 :         await updateSessionUsage();
     478             :       } catch (e) {
     479           0 :         newSession.free();
     480           0 :         throw DecryptException(DecryptException.decryptionFailed, e.toString());
     481             :       }
     482             :     }
     483          23 :     final Map<String, dynamic> plainContent = json.decode(plaintext);
     484          69 :     if (plainContent['sender'] != event.sender) {
     485           0 :       throw DecryptException(DecryptException.senderDoesntMatch);
     486             :     }
     487          92 :     if (plainContent['recipient'] != client.userID) {
     488           0 :       throw DecryptException(DecryptException.recipientDoesntMatch);
     489             :     }
     490          46 :     if (plainContent['recipient_keys'] is Map &&
     491          69 :         plainContent['recipient_keys']['ed25519'] is String &&
     492          92 :         plainContent['recipient_keys']['ed25519'] != fingerprintKey) {
     493           0 :       throw DecryptException(DecryptException.ownFingerprintDoesntMatch);
     494             :     }
     495          23 :     return ToDeviceEvent(
     496          23 :       content: plainContent['content'],
     497          23 :       encryptedContent: event.content,
     498          23 :       type: plainContent['type'],
     499          23 :       sender: event.sender,
     500             :     );
     501             :   }
     502             : 
     503          24 :   Future<List<OlmSession>> getOlmSessionsFromDatabase(String senderKey) async {
     504             :     final olmSessions =
     505         117 :         await encryption.olmDatabase?.getOlmSessions(senderKey, client.userID!);
     506          52 :     return olmSessions?.where((sess) => sess.isValid).toList() ?? [];
     507             :   }
     508             : 
     509          10 :   Future<void> getOlmSessionsForDevicesFromDatabase(
     510             :       List<String> senderKeys) async {
     511          30 :     final rows = await encryption.olmDatabase?.getOlmSessionsForDevices(
     512             :       senderKeys,
     513          20 :       client.userID!,
     514             :     );
     515          10 :     final res = <String, List<OlmSession>>{};
     516          14 :     for (final sess in rows ?? []) {
     517          12 :       res[sess.identityKey] ??= <OlmSession>[];
     518           4 :       if (sess.isValid) {
     519          12 :         res[sess.identityKey]!.add(sess);
     520             :       }
     521             :     }
     522          14 :     for (final entry in res.entries) {
     523          16 :       _olmSessions[entry.key] = entry.value;
     524             :     }
     525             :   }
     526             : 
     527          24 :   Future<List<OlmSession>> getOlmSessions(String senderKey,
     528             :       {bool getFromDb = true}) async {
     529          48 :     var sess = olmSessions[senderKey];
     530           0 :     if ((getFromDb) && (sess == null || sess.isEmpty)) {
     531          24 :       final sessions = await getOlmSessionsFromDatabase(senderKey);
     532          24 :       if (sessions.isEmpty) {
     533          24 :         return [];
     534             :       }
     535           4 :       sess = _olmSessions[senderKey] = sessions;
     536             :     }
     537             :     if (sess == null) {
     538           7 :       return [];
     539             :     }
     540          15 :     sess.sort((a, b) => a.lastReceived == b.lastReceived
     541           0 :         ? (a.sessionId ?? '').compareTo(b.sessionId ?? '')
     542           2 :         : (b.lastReceived ?? DateTime(0))
     543           4 :             .compareTo(a.lastReceived ?? DateTime(0)));
     544             :     return sess;
     545             :   }
     546             : 
     547             :   final Map<String, DateTime> _restoredOlmSessionsTime = {};
     548             : 
     549           7 :   Future<void> restoreOlmSession(String userId, String senderKey) async {
     550          21 :     if (!client.userDeviceKeys.containsKey(userId)) {
     551             :       return;
     552             :     }
     553          10 :     final device = client.userDeviceKeys[userId]!.deviceKeys.values
     554           8 :         .firstWhereOrNull((d) => d.curve25519Key == senderKey);
     555             :     if (device == null) {
     556             :       return;
     557             :     }
     558             :     // per device only one olm session per hour should be restored
     559           2 :     final mapKey = '$userId;$senderKey';
     560           4 :     if (_restoredOlmSessionsTime.containsKey(mapKey) &&
     561           0 :         DateTime.now()
     562           0 :             .subtract(Duration(hours: 1))
     563           0 :             .isBefore(_restoredOlmSessionsTime[mapKey]!)) {
     564           0 :       Logs().w(
     565             :           '[OlmManager] Skipping restore session, one was restored in the past hour');
     566             :       return;
     567             :     }
     568           6 :     _restoredOlmSessionsTime[mapKey] = DateTime.now();
     569           4 :     await startOutgoingOlmSessions([device]);
     570           8 :     await client.sendToDeviceEncrypted([device], EventTypes.Dummy, {});
     571             :   }
     572             : 
     573          24 :   Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
     574          48 :     if (event.type != EventTypes.Encrypted) {
     575             :       return event;
     576             :     }
     577          48 :     final senderKey = event.parsedRoomEncryptedContent.senderKey;
     578          24 :     Future<bool> loadFromDb() async {
     579          24 :       final sessions = await getOlmSessions(senderKey);
     580          24 :       return sessions.isNotEmpty;
     581             :     }
     582             : 
     583          48 :     if (!_olmSessions.containsKey(senderKey)) {
     584          24 :       await loadFromDb();
     585             :     }
     586             :     try {
     587          24 :       event = await _decryptToDeviceEvent(event);
     588          46 :       if (event.type != EventTypes.Encrypted || !(await loadFromDb())) {
     589             :         return event;
     590             :       }
     591             :       // retry to decrypt!
     592           0 :       return _decryptToDeviceEvent(event);
     593             :     } catch (_) {
     594             :       // okay, the thing errored while decrypting. It is safe to assume that the olm session is corrupt and we should generate a new one
     595          24 :       runInRoot(() => restoreOlmSession(event.senderId, senderKey));
     596             : 
     597             :       rethrow;
     598             :     }
     599             :   }
     600             : 
     601          10 :   Future<void> startOutgoingOlmSessions(List<DeviceKeys> deviceKeys) async {
     602          20 :     Logs().v(
     603          20 :         '[OlmManager] Starting session with ${deviceKeys.length} devices...');
     604          10 :     final requestingKeysFrom = <String, Map<String, String>>{};
     605          20 :     for (final device in deviceKeys) {
     606          20 :       if (requestingKeysFrom[device.userId] == null) {
     607          30 :         requestingKeysFrom[device.userId] = {};
     608             :       }
     609          40 :       requestingKeysFrom[device.userId]![device.deviceId!] =
     610             :           'signed_curve25519';
     611             :     }
     612             : 
     613          20 :     final response = await client.claimKeys(requestingKeysFrom, timeout: 10000);
     614             : 
     615          30 :     for (final userKeysEntry in response.oneTimeKeys.entries) {
     616          10 :       final userId = userKeysEntry.key;
     617          30 :       for (final deviceKeysEntry in userKeysEntry.value.entries) {
     618          10 :         final deviceId = deviceKeysEntry.key;
     619             :         final fingerprintKey =
     620          60 :             client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.ed25519Key;
     621             :         final identityKey =
     622          60 :             client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.curve25519Key;
     623          30 :         for (final deviceKey in deviceKeysEntry.value.values) {
     624             :           if (fingerprintKey == null ||
     625             :               identityKey == null ||
     626          10 :               deviceKey is! Map<String, Object?> ||
     627          10 :               !deviceKey.checkJsonSignature(fingerprintKey, userId, deviceId) ||
     628          20 :               deviceKey['key'] is! String) {
     629           0 :             Logs().w(
     630           0 :               'Skipping invalid device key from $userId:$deviceId',
     631             :               deviceKey,
     632             :             );
     633             :             continue;
     634             :           }
     635          30 :           Logs().v('[OlmManager] Starting session with $userId:$deviceId');
     636          10 :           final session = olm.Session();
     637             :           try {
     638          10 :             session.create_outbound(
     639          20 :                 _olmAccount!, identityKey, deviceKey.tryGet<String>('key')!);
     640          20 :             await storeOlmSession(OlmSession(
     641          20 :               key: client.userID!,
     642             :               identityKey: identityKey,
     643          10 :               sessionId: session.session_id(),
     644             :               session: session,
     645             :               lastReceived:
     646          10 :                   DateTime.now(), // we want to use a newly created session
     647             :             ));
     648             :           } catch (e, s) {
     649           0 :             session.free();
     650           0 :             Logs()
     651           0 :                 .e('[LibOlm] Could not create new outbound olm session', e, s);
     652             :           }
     653             :         }
     654             :       }
     655             :     }
     656             :   }
     657             : 
     658             :   /// Encryptes a ToDeviceMessage for the given device with an existing
     659             :   /// olm session.
     660             :   /// Throws `NoOlmSessionFoundException` if there is no olm session with this
     661             :   /// device and none could be created.
     662          10 :   Future<Map<String, dynamic>> encryptToDeviceMessagePayload(
     663             :       DeviceKeys device, String type, Map<String, dynamic> payload,
     664             :       {bool getFromDb = true}) async {
     665             :     final sess =
     666          20 :         await getOlmSessions(device.curve25519Key!, getFromDb: getFromDb);
     667          10 :     if (sess.isEmpty) {
     668           7 :       throw NoOlmSessionFoundException(device);
     669             :     }
     670           7 :     final fullPayload = {
     671             :       'type': type,
     672             :       'content': payload,
     673          14 :       'sender': client.userID,
     674          14 :       'keys': {'ed25519': fingerprintKey},
     675           7 :       'recipient': device.userId,
     676          14 :       'recipient_keys': {'ed25519': device.ed25519Key},
     677             :     };
     678          28 :     final encryptResult = sess.first.session!.encrypt(json.encode(fullPayload));
     679          14 :     await storeOlmSession(sess.first);
     680          14 :     if (encryption.olmDatabase != null) {
     681             :       try {
     682          21 :         await encryption.olmDatabase?.setLastSentMessageUserDeviceKey(
     683          14 :             json.encode({
     684             :               'type': type,
     685             :               'content': payload,
     686             :             }),
     687           7 :             device.userId,
     688           7 :             device.deviceId!);
     689             :       } catch (e, s) {
     690             :         // we can ignore this error, since it would just make us use a different olm session possibly
     691           0 :         Logs().w('Error while updating olm usage timestamp', e, s);
     692             :       }
     693             :     }
     694           7 :     final encryptedBody = <String, dynamic>{
     695             :       'algorithm': AlgorithmTypes.olmV1Curve25519AesSha2,
     696           7 :       'sender_key': identityKey,
     697           7 :       'ciphertext': <String, dynamic>{},
     698             :     };
     699          28 :     encryptedBody['ciphertext'][device.curve25519Key] = {
     700           7 :       'type': encryptResult.type,
     701           7 :       'body': encryptResult.body,
     702             :     };
     703             :     return encryptedBody;
     704             :   }
     705             : 
     706          10 :   Future<Map<String, Map<String, Map<String, dynamic>>>> encryptToDeviceMessage(
     707             :       List<DeviceKeys> deviceKeys,
     708             :       String type,
     709             :       Map<String, dynamic> payload) async {
     710          10 :     final data = <String, Map<String, Map<String, dynamic>>>{};
     711             :     // first check if any of our sessions we want to encrypt for are in the database
     712          20 :     if (encryption.olmDatabase != null) {
     713          10 :       await getOlmSessionsForDevicesFromDatabase(
     714          40 :           deviceKeys.map((d) => d.curve25519Key!).toList());
     715             :     }
     716          10 :     final deviceKeysWithoutSession = List<DeviceKeys>.from(deviceKeys);
     717          20 :     deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
     718          34 :         olmSessions[deviceKeys.curve25519Key]?.isNotEmpty ?? false);
     719          10 :     if (deviceKeysWithoutSession.isNotEmpty) {
     720          10 :       await startOutgoingOlmSessions(deviceKeysWithoutSession);
     721             :     }
     722          20 :     for (final device in deviceKeys) {
     723          30 :       final userData = data[device.userId] ??= {};
     724             :       try {
     725          27 :         userData[device.deviceId!] = await encryptToDeviceMessagePayload(
     726             :             device, type, payload,
     727             :             getFromDb: false);
     728           7 :       } on NoOlmSessionFoundException catch (e) {
     729          14 :         Logs().d('[LibOlm] Error encrypting to-device event', e);
     730             :         continue;
     731             :       } catch (e, s) {
     732           0 :         Logs().wtf('[LibOlm] Error encrypting to-device event', e, s);
     733             :         continue;
     734             :       }
     735             :     }
     736             :     return data;
     737             :   }
     738             : 
     739           1 :   Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
     740           2 :     if (event.type == EventTypes.Dummy) {
     741             :       // We received an encrypted m.dummy. This means that the other end was not able to
     742             :       // decrypt our last message. So, we re-send it.
     743           1 :       final encryptedContent = event.encryptedContent;
     744           2 :       if (encryptedContent == null || encryption.olmDatabase == null) {
     745             :         return;
     746             :       }
     747           2 :       final device = client.getUserDeviceKeysByCurve25519Key(
     748           1 :           encryptedContent.tryGet<String>('sender_key') ?? '');
     749             :       if (device == null) {
     750             :         return; // device not found
     751             :       }
     752           2 :       Logs().v(
     753           3 :           '[OlmManager] Device ${device.userId}:${device.deviceId} generated a new olm session, replaying last sent message...');
     754           2 :       final lastSentMessageRes = await encryption.olmDatabase
     755           3 :           ?.getLastSentMessageUserDeviceKey(device.userId, device.deviceId!);
     756             :       if (lastSentMessageRes == null ||
     757           1 :           lastSentMessageRes.isEmpty ||
     758           2 :           lastSentMessageRes.first.isEmpty) {
     759             :         return;
     760             :       }
     761           2 :       final lastSentMessage = json.decode(lastSentMessageRes.first);
     762             :       // We do *not* want to re-play m.dummy events, as they hold no value except of saying
     763             :       // what olm session is the most recent one. In fact, if we *do* replay them, then
     764             :       // we can easily land in an infinite ping-pong trap!
     765           2 :       if (lastSentMessage['type'] != EventTypes.Dummy) {
     766             :         // okay, time to send the message!
     767           2 :         await client.sendToDeviceEncrypted(
     768           3 :             [device], lastSentMessage['type'], lastSentMessage['content']);
     769             :       }
     770             :     }
     771             :   }
     772             : 
     773          21 :   Future<void> dispose() async {
     774          42 :     await currentUpload?.cancel();
     775          62 :     for (final sessions in olmSessions.values) {
     776          40 :       for (final sess in sessions) {
     777          20 :         sess.dispose();
     778             :       }
     779             :     }
     780          42 :     _olmAccount?.free();
     781          21 :     _olmAccount = null;
     782             :   }
     783             : }
     784             : 
     785             : class NoOlmSessionFoundException implements Exception {
     786             :   final DeviceKeys device;
     787             : 
     788           7 :   NoOlmSessionFoundException(this.device);
     789             : 
     790           7 :   @override
     791             :   String toString() =>
     792          35 :       'No olm session found for ${device.userId}:${device.deviceId}';
     793             : }

Generated by: LCOV version 1.14