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