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

Generated by: LCOV version 2.0-1