/* * Famedly Matrix SDK * Copyright (C) 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 'package:canonical_json/canonical_json.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:matrix/matrix.dart'; import 'package:olm/olm.dart' as olm; import '../../encryption.dart'; enum UserVerifiedStatus { verified, unknown, unknownDevice } class DeviceKeysList { Client client; String userId; bool outdated = true; Map deviceKeys = {}; Map crossSigningKeys = {}; SignableKey? getKey(String id) => deviceKeys[id] ?? crossSigningKeys[id]; CrossSigningKey? getCrossSigningKey(String type) => crossSigningKeys.values.firstWhereOrNull((k) => k.usage.contains(type)); CrossSigningKey? get masterKey => getCrossSigningKey('master'); CrossSigningKey? get selfSigningKey => getCrossSigningKey('self_signing'); CrossSigningKey? get userSigningKey => getCrossSigningKey('user_signing'); UserVerifiedStatus get verified { if (masterKey == null) { return UserVerifiedStatus.unknown; } if (masterKey!.verified) { for (final key in deviceKeys.values) { if (!key.verified) { return UserVerifiedStatus.unknownDevice; } } return UserVerifiedStatus.verified; } else { for (final key in deviceKeys.values) { if (!key.verified) { return UserVerifiedStatus.unknown; } } return UserVerifiedStatus.verified; } } /// Starts a verification with this device. This might need to create a new /// direct chat to send the verification request over this room. For this you /// can set parameters here. Future startVerification({ bool? newDirectChatEnableEncryption, List? newDirectChatInitialState, }) async { final encryption = client.encryption; if (encryption == null) { throw Exception('Encryption not enabled'); } if (userId != client.userID) { // in-room verification with someone else final roomId = await client.startDirectChat( userId, enableEncryption: newDirectChatEnableEncryption, initialState: newDirectChatInitialState, waitForSync: false, ); final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client); final request = KeyVerification(encryption: encryption, room: room, userId: userId); await request.start(); // no need to add to the request client object. As we are doing a room // verification request that'll happen automatically once we know the transaction id return request; } else { // broadcast self-verification final request = KeyVerification( encryption: encryption, userId: userId, deviceId: '*'); await request.start(); encryption.keyVerificationManager.addRequest(request); return request; } } DeviceKeysList.fromDbJson( Map dbEntry, List> childEntries, List> crossSigningEntries, Client cl) : client = cl, userId = dbEntry['user_id'] ?? '' { outdated = dbEntry['outdated']; deviceKeys = {}; for (final childEntry in childEntries) { final entry = DeviceKeys.fromDb(childEntry, client); if (entry.isValid) { deviceKeys[childEntry['device_id']] = entry; } else { outdated = true; } } for (final crossSigningEntry in crossSigningEntries) { final entry = CrossSigningKey.fromDbJson(crossSigningEntry, client); if (entry.isValid) { crossSigningKeys[crossSigningEntry['public_key']] = entry; } else { outdated = true; } } } DeviceKeysList(this.userId, this.client); } class SimpleSignableKey extends MatrixSignableKey { @override String? identifier; SimpleSignableKey.fromJson(Map json) : super.fromJson(json); } abstract class SignableKey extends MatrixSignableKey { Client client; Map? validSignatures; bool? _verified; bool? _blocked; String? get ed25519Key => keys['ed25519:$identifier']; bool get verified => identifier != null && (directVerified || crossVerified) && !(blocked); bool get blocked => _blocked ?? false; set blocked(bool b) => _blocked = b; bool get encryptToDevice => !(blocked) && identifier != null && ed25519Key != null && (client.userDeviceKeys[userId]?.masterKey?.verified ?? false ? verified : true); void setDirectVerified(bool v) { _verified = v; } bool get directVerified => _verified ?? false; bool get crossVerified => hasValidSignatureChain(); bool get signed => hasValidSignatureChain(verifiedOnly: false); SignableKey.fromJson(Map json, Client cl) : client = cl, super.fromJson(json) { _verified = false; _blocked = false; } SimpleSignableKey cloneForSigning() { final newKey = SimpleSignableKey.fromJson(toJson().copy()); newKey.identifier = identifier; (newKey.signatures ??= {}).clear(); return newKey; } String get signingContent { final data = super.toJson().copy(); // some old data might have the custom verified and blocked keys data.remove('verified'); data.remove('blocked'); // remove the keys not needed for signing data.remove('unsigned'); data.remove('signatures'); return String.fromCharCodes(canonicalJson.encode(data)); } bool _verifySignature(String pubKey, String signature, {bool isSignatureWithoutLibolmValid = false}) { olm.Utility olmutil; try { olmutil = olm.Utility(); } catch (e) { // if no libolm is present we land in this catch block, and return the default // set if no libolm is there. Some signatures should be assumed-valid while others // should be assumed-invalid return isSignatureWithoutLibolmValid; } var valid = false; try { olmutil.ed25519_verify(pubKey, signingContent, signature); valid = true; } catch (_) { // bad signature valid = false; } finally { olmutil.free(); } return valid; } bool hasValidSignatureChain( {bool verifiedOnly = true, Set? visited, Set? onlyValidateUserIds}) { if (!client.encryptionEnabled) { return false; } final visited_ = visited ?? {}; final onlyValidateUserIds_ = onlyValidateUserIds ?? {}; final setKey = '$userId;$identifier'; if (visited_.contains(setKey) || (onlyValidateUserIds_.isNotEmpty && !onlyValidateUserIds_.contains(userId))) { return false; // prevent recursion & validate hasValidSignatureChain } visited_.add(setKey); if (signatures == null) return false; for (final signatureEntries in signatures!.entries) { final otherUserId = signatureEntries.key; if (!client.userDeviceKeys.containsKey(otherUserId)) { continue; } // we don't allow transitive trust unless it is for ourself if (otherUserId != userId && otherUserId != client.userID) { continue; } for (final signatureEntry in signatureEntries.value.entries) { final fullKeyId = signatureEntry.key; final signature = signatureEntry.value; final keyId = fullKeyId.substring('ed25519:'.length); // we ignore self-signatures here if (otherUserId == userId && keyId == identifier) { continue; } final key = client.userDeviceKeys[otherUserId]?.deviceKeys[keyId] ?? client.userDeviceKeys[otherUserId]?.crossSigningKeys[keyId]; if (key == null) { continue; } if (onlyValidateUserIds_.isNotEmpty && !onlyValidateUserIds_.contains(key.userId)) { // we don't want to verify keys from this user continue; } if (key.blocked) { continue; // we can't be bothered about this keys signatures } var haveValidSignature = false; var gotSignatureFromCache = false; final fullKeyIdBool = validSignatures ?.tryGetMap(otherUserId) ?.tryGet(fullKeyId); if (fullKeyIdBool == true) { haveValidSignature = true; gotSignatureFromCache = true; } else if (fullKeyIdBool == false) { haveValidSignature = false; gotSignatureFromCache = true; } if (!gotSignatureFromCache && key.ed25519Key != null) { // validate the signature manually haveValidSignature = _verifySignature(key.ed25519Key!, signature); final validSignatures = this.validSignatures ??= {}; if (!validSignatures.containsKey(otherUserId)) { validSignatures[otherUserId] = {}; } validSignatures[otherUserId][fullKeyId] = haveValidSignature; } if (!haveValidSignature) { // no valid signature, this key is useless continue; } if ((verifiedOnly && key.directVerified) || (key is CrossSigningKey && key.usage.contains('master') && key.directVerified && key.userId == client.userID)) { return true; // we verified this key and it is valid...all checks out! } // or else we just recurse into that key and chack if it works out final haveChain = key.hasValidSignatureChain( verifiedOnly: verifiedOnly, visited: visited_, onlyValidateUserIds: onlyValidateUserIds); if (haveChain) { return true; } } } return false; } Future setVerified(bool newVerified, [bool sign = true]) async { _verified = newVerified; final encryption = client.encryption; if (newVerified && sign && encryption != null && client.encryptionEnabled && encryption.crossSigning.signable([this])) { // sign the key! // ignore: unawaited_futures encryption.crossSigning.sign([this]); } } Future setBlocked(bool newBlocked); @override Map toJson() { final data = super.toJson().copy(); // some old data may have the verified and blocked keys which are unneeded now data.remove('verified'); data.remove('blocked'); return data; } @override String toString() => json.encode(toJson()); @override bool operator ==(dynamic other) => (other is SignableKey && other.userId == userId && other.identifier == identifier); } class CrossSigningKey extends SignableKey { @override String? identifier; String? get publicKey => identifier; late List usage; bool get isValid => userId.isNotEmpty && publicKey != null && keys.isNotEmpty && ed25519Key != null; @override Future setVerified(bool newVerified, [bool sign = true]) async { if (!isValid) { throw Exception('setVerified called on invalid key'); } await super.setVerified(newVerified, sign); await client.database ?.setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!); } @override Future setBlocked(bool newBlocked) async { if (!isValid) { throw Exception('setBlocked called on invalid key'); } _blocked = newBlocked; await client.database ?.setBlockedUserCrossSigningKey(newBlocked, userId, publicKey!); } CrossSigningKey.fromMatrixCrossSigningKey(MatrixCrossSigningKey k, Client cl) : super.fromJson(k.toJson().copy(), cl) { final json = toJson(); identifier = k.publicKey; usage = json['usage'].cast(); } CrossSigningKey.fromDbJson(Map dbEntry, Client cl) : super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) { final json = toJson(); identifier = dbEntry['public_key']; usage = json['usage'].cast(); _verified = dbEntry['verified']; _blocked = dbEntry['blocked']; } CrossSigningKey.fromJson(Map json, Client cl) : super.fromJson(json.copy(), cl) { final json = toJson(); usage = json['usage'].cast(); if (keys.isNotEmpty) { identifier = keys.values.first; } } } class DeviceKeys extends SignableKey { @override String? identifier; String? get deviceId => identifier; late List algorithms; late DateTime lastActive; String? get curve25519Key => keys['curve25519:$deviceId']; String? get deviceDisplayName => unsigned?['device_display_name']; bool? _validSelfSignature; bool get selfSigned => _validSelfSignature ?? (_validSelfSignature = (deviceId != null && signatures ?.tryGetMap(userId) ?.tryGet('ed25519:$deviceId') == null ? false // without libolm we still want to be able to add devices. In that case we ofc just can't // verify the signature : _verifySignature( ed25519Key!, signatures![userId]!['ed25519:$deviceId']!, isSignatureWithoutLibolmValid: true))); @override bool get blocked => super.blocked || !selfSigned; bool get isValid => deviceId != null && keys.isNotEmpty && curve25519Key != null && ed25519Key != null && selfSigned; @override Future setVerified(bool newVerified, [bool sign = true]) async { if (!isValid) { //throw Exception('setVerified called on invalid key'); return; } await super.setVerified(newVerified, sign); await client.database ?.setVerifiedUserDeviceKey(newVerified, userId, deviceId!); } @override Future setBlocked(bool newBlocked) async { if (!isValid) { //throw Exception('setBlocked called on invalid key'); return; } _blocked = newBlocked; await client.database ?.setBlockedUserDeviceKey(newBlocked, userId, deviceId!); } DeviceKeys.fromMatrixDeviceKeys(MatrixDeviceKeys k, Client cl, [DateTime? lastActiveTs]) : super.fromJson(k.toJson().copy(), cl) { final json = toJson(); identifier = k.deviceId; algorithms = json['algorithms'].cast(); lastActive = lastActiveTs ?? DateTime.now(); } DeviceKeys.fromDb(Map dbEntry, Client cl) : super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) { final json = toJson(); identifier = dbEntry['device_id']; algorithms = json['algorithms'].cast(); _verified = dbEntry['verified']; _blocked = dbEntry['blocked']; lastActive = DateTime.fromMillisecondsSinceEpoch(dbEntry['last_active'] ?? 0); } DeviceKeys.fromJson(Map json, Client cl) : super.fromJson(json.copy(), cl) { final json = toJson(); identifier = json['device_id']; algorithms = json['algorithms'].cast(); lastActive = DateTime.fromMillisecondsSinceEpoch(0); } KeyVerification startVerification() { if (!isValid) { throw Exception('setVerification called on invalid key'); } final encryption = client.encryption; if (encryption == null) { throw Exception('setVerification called with disabled encryption'); } final request = KeyVerification( encryption: encryption, userId: userId, deviceId: deviceId!); request.start(); encryption.keyVerificationManager.addRequest(request); return request; } }