/* * Famedly Matrix SDK * Copyright (C) 2019, 2020, 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:convert'; import 'dart:async'; import 'package:olm/olm.dart' as olm; import '../matrix.dart'; import '../src/utils/run_in_root.dart'; import 'cross_signing.dart'; import 'key_manager.dart'; import 'key_verification_manager.dart'; import 'olm_manager.dart'; import 'ssss.dart'; import 'utils/bootstrap.dart'; class Encryption { final Client client; final bool debug; bool get enabled => olmManager.enabled; /// Returns the base64 encoded keys to store them in a store. /// This String should **never** leave the device! String? get pickledOlmAccount => olmManager.pickledOlmAccount; String? get fingerprintKey => olmManager.fingerprintKey; String? get identityKey => olmManager.identityKey; late KeyManager keyManager; late OlmManager olmManager; late KeyVerificationManager keyVerificationManager; late CrossSigning crossSigning; late SSSS ssss; Encryption({ required this.client, this.debug = false, }) { ssss = SSSS(this); keyManager = KeyManager(this); olmManager = OlmManager(this); keyVerificationManager = KeyVerificationManager(this); crossSigning = CrossSigning(this); } // initial login passes null to init a new olm account Future init(String? olmAccount) async { await olmManager.init(olmAccount); _backgroundTasksRunning = true; _backgroundTasks(); // start the background tasks } bool isMinOlmVersion(int major, int minor, int patch) { try { final version = olm.get_library_version(); return version[0] > major || (version[0] == major && (version[1] > minor || (version[1] == minor && version[2] >= patch))); } catch (_) { return false; } } Bootstrap bootstrap({void Function()? onUpdate}) => Bootstrap( encryption: this, onUpdate: onUpdate, ); void handleDeviceOneTimeKeysCount( Map? countJson, List? unusedFallbackKeyTypes) { runInRoot(() => olmManager.handleDeviceOneTimeKeysCount( countJson, unusedFallbackKeyTypes)); } void onSync() { keyVerificationManager.cleanup(); } Future handleToDeviceEvent(ToDeviceEvent event) async { if (event.type == EventTypes.RoomKey) { // a new room key. We need to handle this asap, before other // events in /sync are handled await keyManager.handleToDeviceEvent(event); } if ([EventTypes.RoomKeyRequest, EventTypes.ForwardedRoomKey] .contains(event.type)) { // "just" room key request things. We don't need these asap, so we handle // them in the background // ignore: unawaited_futures runInRoot(() => keyManager.handleToDeviceEvent(event)); } if (event.type == EventTypes.Dummy) { // the previous device just had to create a new olm session, due to olm session // corruption. We want to try to send it the last message we just sent it, if possible // ignore: unawaited_futures runInRoot(() => olmManager.handleToDeviceEvent(event)); } if (event.type.startsWith('m.key.verification.')) { // some key verification event. No need to handle it now, we can easily // do this in the background // ignore: unawaited_futures runInRoot(() => keyVerificationManager.handleToDeviceEvent(event)); } if (event.type.startsWith('m.secret.')) { // some ssss thing. We can do this in the background // ignore: unawaited_futures runInRoot(() => ssss.handleToDeviceEvent(event)); } if (event.sender == client.userID) { // maybe we need to re-try SSSS secrets // ignore: unawaited_futures runInRoot(() => ssss.periodicallyRequestMissingCache()); } } Future handleEventUpdate(EventUpdate update) async { if (update.type == EventUpdateType.ephemeral || update.type == EventUpdateType.history) { return; } if (update.content['type'].startsWith('m.key.verification.') || (update.content['type'] == EventTypes.Message && (update.content['content']['msgtype'] is String) && update.content['content']['msgtype'] .startsWith('m.key.verification.'))) { // "just" key verification, no need to do this in sync // ignore: unawaited_futures runInRoot(() => keyVerificationManager.handleEventUpdate(update)); } if (update.content['sender'] == client.userID && update.content['unsigned']?['transaction_id'] == null) { // maybe we need to re-try SSSS secrets // ignore: unawaited_futures runInRoot(() => ssss.periodicallyRequestMissingCache()); } } Future decryptToDeviceEvent(ToDeviceEvent event) async { try { return await olmManager.decryptToDeviceEvent(event); } catch (e, s) { Logs().w( '[LibOlm] Could not decrypt to device event from ${event.sender} with content: ${event.content}', e, s); client.onEncryptionError.add( SdkError( exception: e is Exception ? e : Exception(e), stackTrace: s, ), ); return event; } } Event decryptRoomEventSync(String roomId, Event event) { final content = event.parsedRoomEncryptedContent; if (event.type != EventTypes.Encrypted || content.ciphertextMegolm == null) { return event; } Map decryptedPayload; var canRequestSession = false; try { if (content.algorithm != AlgorithmTypes.megolmV1AesSha2) { throw DecryptException(DecryptException.unknownAlgorithm); } final sessionId = content.sessionId; final senderKey = content.senderKey; if (sessionId == null) { throw DecryptException(DecryptException.unknownSession); } final inboundGroupSession = keyManager.getInboundGroupSession(roomId, sessionId, senderKey); if (!(inboundGroupSession?.isValid ?? false)) { canRequestSession = true; throw DecryptException(DecryptException.unknownSession); } // decrypt errors here may mean we have a bad session key - others might have a better one canRequestSession = true; final decryptResult = inboundGroupSession!.inboundGroupSession! .decrypt(content.ciphertextMegolm!); canRequestSession = false; // we can't have the key be an int, else json-serializing will fail, thus we need it to be a string final messageIndexKey = 'key-' + decryptResult.message_index.toString(); final messageIndexValue = event.eventId + '|' + event.originServerTs.millisecondsSinceEpoch.toString(); final haveIndex = inboundGroupSession.indexes.containsKey(messageIndexKey); if (haveIndex && inboundGroupSession.indexes[messageIndexKey] != messageIndexValue) { Logs().e('[Decrypt] Could not decrypt due to a corrupted session.'); throw DecryptException(DecryptException.channelCorrupted); } inboundGroupSession.indexes[messageIndexKey] = messageIndexValue; if (!haveIndex) { // now we persist the udpated indexes into the database. // the entry should always exist. In the case it doesn't, the following // line *could* throw an error. As that is a future, though, and we call // it un-awaited here, nothing happens, which is exactly the result we want client.database?.updateInboundGroupSessionIndexes( json.encode(inboundGroupSession.indexes), roomId, sessionId); } decryptedPayload = json.decode(decryptResult.plaintext); } catch (exception) { // alright, if this was actually by our own outbound group session, we might as well clear it if (exception.toString() != DecryptException.unknownSession && (keyManager .getOutboundGroupSession(roomId) ?.outboundGroupSession ?.session_id() ?? '') == content.sessionId) { runInRoot(() => keyManager.clearOrUseOutboundGroupSession(roomId, wipe: true)); } if (canRequestSession) { decryptedPayload = { 'content': event.content, 'type': EventTypes.Encrypted, }; decryptedPayload['content']['body'] = exception.toString(); decryptedPayload['content']['msgtype'] = MessageTypes.BadEncrypted; decryptedPayload['content']['can_request_session'] = true; } else { decryptedPayload = { 'content': { 'msgtype': MessageTypes.BadEncrypted, 'body': exception.toString(), }, 'type': EventTypes.Encrypted, }; } } if (event.content['m.relates_to'] != null) { decryptedPayload['content']['m.relates_to'] = event.content['m.relates_to']; } return Event( content: decryptedPayload['content'], type: decryptedPayload['type'], senderId: event.senderId, eventId: event.eventId, room: event.room, originServerTs: event.originServerTs, unsigned: event.unsigned, stateKey: event.stateKey, prevContent: event.prevContent, status: event.status, ); } Future decryptRoomEvent(String roomId, Event event, {bool store = false, EventUpdateType updateType = EventUpdateType.timeline}) async { if (event.type != EventTypes.Encrypted) { return event; } final content = event.parsedRoomEncryptedContent; final sessionId = content.sessionId; try { if (client.database != null && sessionId != null && !(keyManager .getInboundGroupSession( roomId, sessionId, content.senderKey, ) ?.isValid ?? false)) { await keyManager.loadInboundGroupSession( roomId, sessionId, content.senderKey, ); } event = decryptRoomEventSync(roomId, event); if (event.type == EventTypes.Encrypted && event.content['can_request_session'] == true && sessionId != null) { keyManager.maybeAutoRequest( roomId, sessionId, content.senderKey, ); } if (event.type != EventTypes.Encrypted && store) { if (updateType != EventUpdateType.history) { event.room.setState(event); } await client.database?.storeEventUpdate( EventUpdate( content: event.toJson(), roomID: roomId, type: updateType, ), client, ); } return event; } catch (e, s) { Logs().e('[Decrypt] Could not decrpyt event', e, s); return event; } } /// Encrypts the given json payload and creates a send-ready m.room.encrypted /// payload. This will create a new outgoingGroupSession if necessary. Future> encryptGroupMessagePayload( String roomId, Map payload, {String type = EventTypes.Message}) async { final Map? mRelatesTo = payload.remove('m.relates_to'); // Events which only contain a m.relates_to like reactions don't need to // be encrypted. if (payload.isEmpty && mRelatesTo != null) { return {'m.relates_to': mRelatesTo}; } final room = client.getRoomById(roomId); if (room == null || !room.encrypted || !enabled) { return payload; } if (room.encryptionAlgorithm != AlgorithmTypes.megolmV1AesSha2) { throw ('Unknown encryption algorithm'); } if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) { await keyManager.loadOutboundGroupSession(roomId); } await keyManager.clearOrUseOutboundGroupSession(roomId); if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) { await keyManager.createOutboundGroupSession(roomId); } final sess = keyManager.getOutboundGroupSession(roomId); if (sess?.isValid != true) { throw ('Unable to create new outbound group session'); } // we clone the payload as we do not want to remove 'm.relates_to' from the // original payload passed into this function payload = payload.copy(); final payloadContent = { 'content': payload, 'type': type, 'room_id': roomId, }; final encryptedPayload = { 'algorithm': AlgorithmTypes.megolmV1AesSha2, 'ciphertext': sess!.outboundGroupSession!.encrypt(json.encode(payloadContent)), 'device_id': client.deviceID, 'sender_key': identityKey, 'session_id': sess.outboundGroupSession!.session_id(), if (mRelatesTo != null) 'm.relates_to': mRelatesTo, }; await keyManager.storeOutboundGroupSession(roomId, sess); return encryptedPayload; } Future> encryptToDeviceMessage( List deviceKeys, String type, Map payload) async { return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload); } Future autovalidateMasterOwnKey() async { // check if we can set our own master key as verified, if it isn't yet final userId = client.userID; final masterKey = client.userDeviceKeys[userId]?.masterKey; if (client.database != null && masterKey != null && userId != null && !masterKey.directVerified && masterKey.hasValidSignatureChain(onlyValidateUserIds: {userId})) { await masterKey.setVerified(true); } } // this method is responsible for all background tasks, such as uploading online key backups bool _backgroundTasksRunning = true; void _backgroundTasks() { if (!_backgroundTasksRunning || !client.isLogged()) { return; } keyManager.backgroundTasks(); // autovalidateMasterOwnKey(); if (_backgroundTasksRunning) { Timer(Duration(seconds: 10), _backgroundTasks); } } void dispose() { _backgroundTasksRunning = false; keyManager.dispose(); olmManager.dispose(); keyVerificationManager.dispose(); } } class DecryptException implements Exception { String cause; String? libolmMessage; DecryptException(this.cause, [this.libolmMessage]); @override String toString() => cause + (libolmMessage != null ? ': $libolmMessage' : ''); static const String notEnabled = 'Encryption is not enabled in your client.'; static const String unknownAlgorithm = 'Unknown encryption algorithm.'; static const String unknownSession = 'The sender has not sent us the session key.'; static const String channelCorrupted = 'The secure channel with the sender was corrupted.'; static const String unableToDecryptWithAnyOlmSession = 'Unable to decrypt with any existing OLM session'; static const String senderDoesntMatch = "Message was decrypted but sender doesn't match"; static const String recipientDoesntMatch = "Message was decrypted but recipient doesn't match"; static const String ownFingerprintDoesntMatch = "Message was decrypted but own fingerprint Key doesn't match"; static const String isntSentForThisDevice = "The message isn't sent for this device"; static const String unknownMessageType = 'Unknown message type'; static const String decryptionFailed = 'Decryption failed'; }