/* * Famedly Matrix SDK * Copyright (C) 2019, 2020 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 'package:canonical_json/canonical_json.dart'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/matrix_api.dart'; import 'package:olm/olm.dart' as olm; import 'package:pedantic/pedantic.dart'; import '../encryption/utils/json_signature_check_extension.dart'; import '../src/utils/logs.dart'; import 'encryption.dart'; import 'utils/olm_session.dart'; class OlmManager { final Encryption encryption; Client get client => encryption.client; olm.Account _olmAccount; /// Returns the base64 encoded keys to store them in a store. /// This String should **never** leave the device! String get pickledOlmAccount => enabled ? _olmAccount.pickle(client.userID) : null; String get fingerprintKey => enabled ? json.decode(_olmAccount.identity_keys())['ed25519'] : null; String get identityKey => enabled ? json.decode(_olmAccount.identity_keys())['curve25519'] : null; bool get enabled => _olmAccount != null; OlmManager(this.encryption); /// A map from Curve25519 identity keys to existing olm sessions. Map> get olmSessions => _olmSessions; final Map> _olmSessions = {}; Future init(String olmAccount) async { if (olmAccount == null) { try { await olm.init(); _olmAccount = olm.Account(); _olmAccount.create(); if (await uploadKeys(uploadDeviceKeys: true) == false) { throw ('Upload key failed'); } } catch (_) { _olmAccount?.free(); _olmAccount = null; } } else { try { await olm.init(); _olmAccount = olm.Account(); _olmAccount.unpickle(client.userID, olmAccount); } catch (_) { _olmAccount?.free(); _olmAccount = null; } } } /// Adds a signature to this json from this olm account and returns the signed /// json. Map signJson(Map payload) { if (!enabled) throw ('Encryption is disabled'); final Map unsigned = payload['unsigned']; final Map signatures = payload['signatures']; payload.remove('unsigned'); payload.remove('signatures'); final canonical = canonicalJson.encode(payload); final signature = _olmAccount.sign(String.fromCharCodes(canonical)); if (signatures != null) { payload['signatures'] = signatures; } else { payload['signatures'] = {}; } if (!payload['signatures'].containsKey(client.userID)) { payload['signatures'][client.userID] = {}; } payload['signatures'][client.userID]['ed25519:${client.deviceID}'] = signature; if (unsigned != null) { payload['unsigned'] = unsigned; } return payload; } String signString(String s) { return _olmAccount.sign(s); } /// Checks the signature of a signed json object. @deprecated bool checkJsonSignature(String key, Map signedJson, String userId, String deviceId) { if (!enabled) throw ('Encryption is disabled'); final Map signatures = signedJson['signatures']; if (signatures == null || !signatures.containsKey(userId)) return false; signedJson.remove('unsigned'); signedJson.remove('signatures'); if (!signatures[userId].containsKey('ed25519:$deviceId')) return false; final String signature = signatures[userId]['ed25519:$deviceId']; final canonical = canonicalJson.encode(signedJson); final message = String.fromCharCodes(canonical); var isValid = false; final olmutil = olm.Utility(); try { olmutil.ed25519_verify(key, message, signature); isValid = true; } catch (e, s) { isValid = false; Logs.error('[LibOlm] Signature check failed: ' + e.toString(), s); } finally { olmutil.free(); } return isValid; } bool _uploadKeysLock = false; /// Generates new one time keys, signs everything and upload it to the server. Future uploadKeys( {bool uploadDeviceKeys = false, int oldKeyCount = 0}) async { if (!enabled) { return true; } if (_uploadKeysLock) { return false; } _uploadKeysLock = true; try { // generate one-time keys // we generate 2/3rds of max, so that other keys people may still have can // still be used final oneTimeKeysCount = (_olmAccount.max_number_of_one_time_keys() * 2 / 3).floor() - oldKeyCount; _olmAccount.generate_one_time_keys(oneTimeKeysCount); final Map oneTimeKeys = json.decode(_olmAccount.one_time_keys()); // now sign all the one-time keys final signedOneTimeKeys = {}; for (final entry in oneTimeKeys['curve25519'].entries) { final key = entry.key; final value = entry.value; signedOneTimeKeys['signed_curve25519:$key'] = {}; signedOneTimeKeys['signed_curve25519:$key'] = signJson({ 'key': value, }); } // and now generate the payload to upload final keysContent = { if (uploadDeviceKeys) 'device_keys': { 'user_id': client.userID, 'device_id': client.deviceID, 'algorithms': [ 'm.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2' ], 'keys': {}, }, }; if (uploadDeviceKeys) { final Map keys = json.decode(_olmAccount.identity_keys()); for (final entry in keys.entries) { final algorithm = entry.key; final value = entry.value; keysContent['device_keys']['keys']['$algorithm:${client.deviceID}'] = value; } keysContent['device_keys'] = signJson(keysContent['device_keys'] as Map); } final response = await client.uploadDeviceKeys( deviceKeys: uploadDeviceKeys ? MatrixDeviceKeys.fromJson(keysContent['device_keys']) : null, oneTimeKeys: signedOneTimeKeys, ); _olmAccount.mark_keys_as_published(); await client.database?.updateClientKeys(pickledOlmAccount, client.id); return response['signed_curve25519'] == oneTimeKeysCount; } finally { _uploadKeysLock = false; } } void handleDeviceOneTimeKeysCount(Map countJson) { if (!enabled) { return; } // Check if there are at least half of max_number_of_one_time_keys left on the server // and generate and upload more if not. if (countJson.containsKey('signed_curve25519') && countJson['signed_curve25519'] < (_olmAccount.max_number_of_one_time_keys() / 2)) { uploadKeys(oldKeyCount: countJson['signed_curve25519']); } } void storeOlmSession(OlmSession session) { if (client.database == null) { return; } if (!_olmSessions.containsKey(session.identityKey)) { _olmSessions[session.identityKey] = []; } final ix = _olmSessions[session.identityKey] .indexWhere((s) => s.sessionId == session.sessionId); if (ix == -1) { // add a new session _olmSessions[session.identityKey].add(session); } else { // update an existing session _olmSessions[session.identityKey][ix] = session; } client.database.storeOlmSession(client.id, session.identityKey, session.sessionId, session.pickledSession, session.lastReceived); } ToDeviceEvent _decryptToDeviceEvent(ToDeviceEvent event) { if (event.type != EventTypes.Encrypted) { return event; } if (event.content['algorithm'] != 'm.olm.v1.curve25519-aes-sha2') { throw ('Unknown algorithm: ${event.content['algorithm']}'); } if (!event.content['ciphertext'].containsKey(identityKey)) { throw ("The message isn't sent for this device"); } String plaintext; final String senderKey = event.content['sender_key']; final String body = event.content['ciphertext'][identityKey]['body']; final int type = event.content['ciphertext'][identityKey]['type']; if (type != 0 && type != 1) { throw ('Unknown message type'); } var existingSessions = olmSessions[senderKey]; if (existingSessions != null) { for (var session in existingSessions) { if (type == 0 && session.session.matches_inbound(body) == true) { plaintext = session.session.decrypt(type, body); session.lastReceived = DateTime.now(); storeOlmSession(session); break; } else if (type == 1) { try { plaintext = session.session.decrypt(type, body); session.lastReceived = DateTime.now(); storeOlmSession(session); break; } catch (_) { plaintext = null; } } } } if (plaintext == null && type != 0) { return event; } if (plaintext == null) { var newSession = olm.Session(); try { newSession.create_inbound_from(_olmAccount, senderKey, body); _olmAccount.remove_one_time_keys(newSession); client.database?.updateClientKeys(pickledOlmAccount, client.id); plaintext = newSession.decrypt(type, body); storeOlmSession(OlmSession( key: client.userID, identityKey: senderKey, sessionId: newSession.session_id(), session: newSession, lastReceived: DateTime.now(), )); } catch (_) { newSession?.free(); rethrow; } } final Map plainContent = json.decode(plaintext); if (plainContent.containsKey('sender') && plainContent['sender'] != event.sender) { throw ("Message was decrypted but sender doesn't match"); } if (plainContent.containsKey('recipient') && plainContent['recipient'] != client.userID) { throw ("Message was decrypted but recipient doesn't match"); } if (plainContent['recipient_keys'] is Map && plainContent['recipient_keys']['ed25519'] is String && plainContent['recipient_keys']['ed25519'] != fingerprintKey) { throw ("Message was decrypted but own fingerprint Key doesn't match"); } return ToDeviceEvent( content: plainContent['content'], encryptedContent: event.content, type: plainContent['type'], sender: event.sender, ); } Future> getOlmSessionsFromDatabase(String senderKey) async { if (client.database == null) { return []; } final rows = await client.database.dbGetOlmSessions(client.id, senderKey).get(); final res = []; for (final row in rows) { final sess = OlmSession.fromDb(row, client.userID); if (sess.isValid) { res.add(sess); } } return res; } Future restoreOlmSession(String userId, String senderKey) async { if (!client.userDeviceKeys.containsKey(userId)) { return; } final device = client.userDeviceKeys[userId].deviceKeys.values .firstWhere((d) => d.curve25519Key == senderKey, orElse: () => null); if (device == null) { return; } await startOutgoingOlmSessions([device]); await client.sendToDeviceEncrypted([device], 'm.dummy', {}); } Future decryptToDeviceEvent(ToDeviceEvent event) async { if (event.type != EventTypes.Encrypted) { return event; } final senderKey = event.content['sender_key']; final loadFromDb = () async { if (client.database == null) { return false; } final sessions = await getOlmSessionsFromDatabase(senderKey); if (sessions.isEmpty) { return false; // okay, can't do anything } _olmSessions[senderKey] = sessions; return true; }; if (!_olmSessions.containsKey(senderKey)) { await loadFromDb(); } try { event = _decryptToDeviceEvent(event); if (event.type != EventTypes.Encrypted || !(await loadFromDb())) { return event; } // retry to decrypt! return _decryptToDeviceEvent(event); } catch (_) { // okay, the thing errored while decrypting. It is safe to assume that the olm session is corrupt and we should generate a new one if (client.enableE2eeRecovery) { unawaited(restoreOlmSession(event.senderId, senderKey)); } rethrow; } } Future startOutgoingOlmSessions(List deviceKeys) async { var requestingKeysFrom = >{}; for (var device in deviceKeys) { if (requestingKeysFrom[device.userId] == null) { requestingKeysFrom[device.userId] = {}; } requestingKeysFrom[device.userId][device.deviceId] = 'signed_curve25519'; } final response = await client.requestOneTimeKeys(requestingKeysFrom, timeout: 10000); for (var userKeysEntry in response.oneTimeKeys.entries) { final userId = userKeysEntry.key; for (var deviceKeysEntry in userKeysEntry.value.entries) { final deviceId = deviceKeysEntry.key; final fingerprintKey = client.userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key; final identityKey = client.userDeviceKeys[userId].deviceKeys[deviceId].curve25519Key; for (Map deviceKey in deviceKeysEntry.value.values) { if (!deviceKey.checkJsonSignature(fingerprintKey, userId, deviceId)) { continue; } var session = olm.Session(); try { session.create_outbound(_olmAccount, identityKey, deviceKey['key']); await storeOlmSession(OlmSession( key: client.userID, identityKey: identityKey, sessionId: session.session_id(), session: session, lastReceived: DateTime.now(), // we want to use a newly created session )); } catch (e, s) { session.free(); Logs.error( '[LibOlm] Could not create new outbound olm session: ' + e.toString(), s); } } } } } Future> encryptToDeviceMessagePayload( DeviceKeys device, String type, Map payload) async { var sess = olmSessions[device.curve25519Key]; if (sess == null || sess.isEmpty) { final sessions = await getOlmSessionsFromDatabase(device.curve25519Key); if (sessions.isEmpty) { throw ('No olm session found'); } sess = _olmSessions[device.curve25519Key] = sessions; } sess.sort((a, b) => a.lastReceived == b.lastReceived ? a.sessionId.compareTo(b.sessionId) : b.lastReceived.compareTo(a.lastReceived)); final fullPayload = { 'type': type, 'content': payload, 'sender': client.userID, 'keys': {'ed25519': fingerprintKey}, 'recipient': device.userId, 'recipient_keys': {'ed25519': device.ed25519Key}, }; final encryptResult = sess.first.session.encrypt(json.encode(fullPayload)); storeOlmSession(sess.first); final encryptedBody = { 'algorithm': 'm.olm.v1.curve25519-aes-sha2', 'sender_key': identityKey, 'ciphertext': {}, }; encryptedBody['ciphertext'][device.curve25519Key] = { 'type': encryptResult.type, 'body': encryptResult.body, }; return encryptedBody; } Future> encryptToDeviceMessage( List deviceKeys, String type, Map payload) async { var data = >>{}; // first check if any of our sessions we want to encrypt for are in the database if (client.database != null) { for (final device in deviceKeys) { if (!olmSessions.containsKey(device.curve25519Key)) { final sessions = await getOlmSessionsFromDatabase(device.curve25519Key); if (sessions.isNotEmpty) { _olmSessions[device.curve25519Key] = sessions; } } } } final deviceKeysWithoutSession = List.from(deviceKeys); deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) => olmSessions.containsKey(deviceKeys.curve25519Key)); if (deviceKeysWithoutSession.isNotEmpty) { await startOutgoingOlmSessions(deviceKeysWithoutSession); } for (final device in deviceKeys) { if (!data.containsKey(device.userId)) { data[device.userId] = {}; } try { data[device.userId][device.deviceId] = await encryptToDeviceMessagePayload(device, type, payload); } catch (e, s) { Logs.error( '[LibOlm] Error encrypting to-device event: ' + e.toString(), s); continue; } } return data; } void dispose() { for (final sessions in olmSessions.values) { for (final sess in sessions) { sess.dispose(); } } _olmAccount?.free(); _olmAccount = null; } }