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

Generated by: LCOV version 2.0-1