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