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 72 : 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 23 : String? get pickledOlmAccount =>
43 115 : enabled ? _olmAccount!.pickle(client.userID!) : null;
44 23 : String? get fingerprintKey =>
45 115 : enabled ? json.decode(_olmAccount!.identity_keys())['ed25519'] : null;
46 24 : String? get identityKey =>
47 120 : enabled ? json.decode(_olmAccount!.identity_keys())['curve25519'] : null;
48 :
49 0 : String? pickleOlmAccountWithKey(String key) =>
50 0 : enabled ? _olmAccount!.pickle(key) : null;
51 :
52 48 : bool get enabled => _olmAccount != null;
53 :
54 24 : OlmManager(this.encryption);
55 :
56 : /// A map from Curve25519 identity keys to existing olm sessions.
57 48 : 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 24 : Future<void> init({
62 : String? olmAccount,
63 : required String? deviceId,
64 : String? pickleKey,
65 : String? dehydratedDeviceAlgorithm,
66 : }) async {
67 24 : 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 23 : await olm.init();
90 46 : _olmAccount = olm.Account();
91 92 : _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 42 : 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 24 : Future<void> handleDeviceOneTimeKeysCount(
336 : Map<String, int>? countJson,
337 : List<String>? unusedFallbackKeyTypes,
338 : ) async {
339 24 : if (!enabled) {
340 : return;
341 : }
342 :
343 48 : await _otkUpdateDedup.fetch(
344 72 : () => runBenchmarked('handleOtkUpdate', () async {
345 48 : 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 24 : 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 26 : 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 72 : 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 96 : 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 23 : Future<void> storeOlmSession(OlmSession session) async {
383 46 : if (session.sessionId == null || session.pickledSession == null) {
384 : return;
385 : }
386 :
387 92 : _olmSessions[session.identityKey] ??= <OlmSession>[];
388 69 : final ix = _olmSessions[session.identityKey]!
389 55 : .indexWhere((s) => s.sessionId == session.sessionId);
390 46 : if (ix == -1) {
391 : // add a new session
392 92 : _olmSessions[session.identityKey]!.add(session);
393 : } else {
394 : // update an existing session
395 28 : _olmSessions[session.identityKey]![ix] = session;
396 : }
397 69 : await encryption.olmDatabase?.storeOlmSession(
398 23 : session.identityKey,
399 23 : session.sessionId!,
400 23 : session.pickledSession!,
401 46 : session.lastReceived?.millisecondsSinceEpoch ??
402 0 : DateTime.now().millisecondsSinceEpoch,
403 : );
404 : }
405 :
406 24 : Future<ToDeviceEvent> _decryptToDeviceEvent(ToDeviceEvent event) async {
407 48 : if (event.type != EventTypes.Encrypted) {
408 : return event;
409 : }
410 24 : final content = event.parsedRoomEncryptedContent;
411 48 : if (content.algorithm != AlgorithmTypes.olmV1Curve25519AesSha2) {
412 0 : throw DecryptException(DecryptException.unknownAlgorithm);
413 : }
414 24 : if (content.ciphertextOlm == null ||
415 72 : !content.ciphertextOlm!.containsKey(identityKey)) {
416 6 : throw DecryptException(DecryptException.isntSentForThisDevice);
417 : }
418 : String? plaintext;
419 23 : final senderKey = content.senderKey;
420 92 : final body = content.ciphertextOlm![identityKey]!.body;
421 92 : final type = content.ciphertextOlm![identityKey]!.type;
422 23 : if (type != 0 && type != 1) {
423 0 : throw DecryptException(DecryptException.unknownMessageType);
424 : }
425 96 : final device = client.userDeviceKeys[event.sender]?.deviceKeys.values
426 8 : .firstWhereOrNull((d) => d.curve25519Key == senderKey);
427 46 : final existingSessions = olmSessions[senderKey];
428 23 : 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 23 : if (plaintext == null && type != 0) {
476 0 : throw DecryptException(DecryptException.unableToDecryptWithAnyOlmSession);
477 : }
478 :
479 : if (plaintext == null) {
480 23 : final newSession = olm.Session();
481 : try {
482 46 : newSession.create_inbound_from(_olmAccount!, senderKey, body);
483 46 : _olmAccount!.remove_one_time_keys(newSession);
484 92 : await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
485 :
486 23 : plaintext = newSession.decrypt(type, body);
487 :
488 23 : await storeOlmSession(
489 23 : OlmSession(
490 46 : key: client.userID!,
491 : identityKey: senderKey,
492 23 : sessionId: newSession.session_id(),
493 : session: newSession,
494 23 : lastReceived: DateTime.now(),
495 : ),
496 : );
497 23 : await updateSessionUsage();
498 : } catch (e) {
499 0 : newSession.free();
500 0 : throw DecryptException(DecryptException.decryptionFailed, e.toString());
501 : }
502 : }
503 23 : final Map<String, dynamic> plainContent = json.decode(plaintext);
504 69 : if (plainContent['sender'] != event.sender) {
505 0 : throw DecryptException(DecryptException.senderDoesntMatch);
506 : }
507 92 : if (plainContent['recipient'] != client.userID) {
508 0 : throw DecryptException(DecryptException.recipientDoesntMatch);
509 : }
510 46 : if (plainContent['recipient_keys'] is Map &&
511 69 : plainContent['recipient_keys']['ed25519'] is String &&
512 92 : plainContent['recipient_keys']['ed25519'] != fingerprintKey) {
513 0 : throw DecryptException(DecryptException.ownFingerprintDoesntMatch);
514 : }
515 23 : return ToDeviceEvent(
516 23 : content: plainContent['content'],
517 23 : encryptedContent: event.content,
518 23 : type: plainContent['type'],
519 23 : sender: event.sender,
520 : );
521 : }
522 :
523 24 : Future<List<OlmSession>> getOlmSessionsFromDatabase(String senderKey) async {
524 : final olmSessions =
525 117 : await encryption.olmDatabase?.getOlmSessions(senderKey, client.userID!);
526 52 : 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 24 : Future<List<OlmSession>> getOlmSessions(
549 : String senderKey, {
550 : bool getFromDb = true,
551 : }) async {
552 48 : var sess = olmSessions[senderKey];
553 0 : if ((getFromDb) && (sess == null || sess.isEmpty)) {
554 24 : final sessions = await getOlmSessionsFromDatabase(senderKey);
555 24 : if (sessions.isEmpty) {
556 24 : 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 24 : Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
600 48 : if (event.type != EventTypes.Encrypted) {
601 : return event;
602 : }
603 48 : final senderKey = event.parsedRoomEncryptedContent.senderKey;
604 24 : Future<bool> loadFromDb() async {
605 24 : final sessions = await getOlmSessions(senderKey);
606 24 : return sessions.isNotEmpty;
607 : }
608 :
609 48 : if (!_olmSessions.containsKey(senderKey)) {
610 24 : await loadFromDb();
611 : }
612 : try {
613 24 : event = await _decryptToDeviceEvent(event);
614 46 : 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 21 : Future<void> dispose() async {
822 26 : await currentUpload?.cancel();
823 62 : for (final sessions in olmSessions.values) {
824 40 : for (final sess in sessions) {
825 20 : sess.dispose();
826 : }
827 : }
828 42 : _olmAccount?.free();
829 21 : _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 : }
|