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