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