diff --git a/lib/encryption.dart b/lib/encryption.dart index ef4f347..2239ee2 100644 --- a/lib/encryption.dart +++ b/lib/encryption.dart @@ -20,4 +20,5 @@ library encryption; export './encryption/encryption.dart'; export './encryption/key_manager.dart'; +export './encryption/ssss.dart'; export './encryption/utils/key_verification.dart'; diff --git a/lib/encryption/cross_signing.dart b/lib/encryption/cross_signing.dart new file mode 100644 index 0000000..92cbb86 --- /dev/null +++ b/lib/encryption/cross_signing.dart @@ -0,0 +1,183 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 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:typed_data'; +import 'dart:convert'; + +import 'package:olm/olm.dart' as olm; +import 'package:famedlysdk/famedlysdk.dart'; + +import 'encryption.dart'; + +const SELF_SIGNING_KEY = 'm.cross_signing.self_signing'; +const USER_SIGNING_KEY = 'm.cross_signing.user_signing'; +const MASTER_KEY = 'm.cross_signing.master'; + +class CrossSigning { + final Encryption encryption; + Client get client => encryption.client; + CrossSigning(this.encryption) { + encryption.ssss.setValidator(SELF_SIGNING_KEY, (String secret) async { + final keyObj = olm.PkSigning(); + try { + return keyObj.init_with_seed(base64.decode(secret)) == + client.userDeviceKeys[client.userID].selfSigningKey.ed25519Key; + } catch (_) { + return false; + } finally { + keyObj.free(); + } + }); + encryption.ssss.setValidator(USER_SIGNING_KEY, (String secret) async { + final keyObj = olm.PkSigning(); + try { + return keyObj.init_with_seed(base64.decode(secret)) == + client.userDeviceKeys[client.userID].userSigningKey.ed25519Key; + } catch (_) { + return false; + } finally { + keyObj.free(); + } + }); + } + + bool get enabled => + client.accountData[SELF_SIGNING_KEY] != null && + client.accountData[USER_SIGNING_KEY] != null && + client.accountData[MASTER_KEY] != null; + + Future isCached() async { + if (!enabled) { + return false; + } + return (await encryption.ssss.getCached(SELF_SIGNING_KEY)) != null && + (await encryption.ssss.getCached(USER_SIGNING_KEY)) != null; + } + + Future selfSign({String passphrase, String recoveryKey}) async { + final handle = encryption.ssss.open(MASTER_KEY); + await handle.unlock(passphrase: passphrase, recoveryKey: recoveryKey); + await handle.maybeCacheAll(); + final masterPrivateKey = base64.decode(await handle.getStored(MASTER_KEY)); + final keyObj = olm.PkSigning(); + String masterPubkey; + try { + masterPubkey = keyObj.init_with_seed(masterPrivateKey); + } finally { + keyObj.free(); + } + if (masterPubkey == null || + !client.userDeviceKeys.containsKey(client.userID) || + !client.userDeviceKeys[client.userID].deviceKeys + .containsKey(client.deviceID)) { + throw 'Master or user keys not found'; + } + final masterKey = client.userDeviceKeys[client.userID].masterKey; + if (masterKey == null || masterKey.ed25519Key != masterPubkey) { + throw 'Master pubkey key doesn\'t match'; + } + // master key is valid, set it to verified + await masterKey.setVerified(true, false); + // and now sign both our own key and our master key + await sign([ + masterKey, + client.userDeviceKeys[client.userID].deviceKeys[client.deviceID] + ]); + } + + bool signable(List keys) { + for (final key in keys) { + if (key is CrossSigningKey && key.usage.contains('master')) { + return true; + } + if (key.userId == client.userID && + (key is DeviceKeys) && + key.identifier != client.deviceID) { + return true; + } + } + return false; + } + + Future sign(List keys) async { + Uint8List selfSigningKey; + Uint8List userSigningKey; + final signedKeys = []; + final addSignature = + (SignableKey key, SignableKey signedWith, String signature) { + if (key == null || signedWith == null || signature == null) { + return; + } + final signedKey = key.cloneForSigning(); + signedKey.signatures[signedWith.userId] = {}; + signedKey.signatures[signedWith.userId] + ['ed25519:${signedWith.identifier}'] = signature; + signedKeys.add(signedKey); + }; + for (final key in keys) { + if (key.userId == client.userID) { + // we are singing a key of ourself + if (key is CrossSigningKey) { + if (key.usage.contains('master')) { + // okay, we'll sign our own master key + final signature = + encryption.olmManager.signString(key.signingContent); + addSignature( + key, + client + .userDeviceKeys[client.userID].deviceKeys[client.deviceID], + signature); + } + // we don't care about signing other cross-signing keys + } else { + // okay, we'll sign a device key with our self signing key + selfSigningKey ??= base64 + .decode(await encryption.ssss.getCached(SELF_SIGNING_KEY) ?? ''); + if (selfSigningKey.isNotEmpty) { + final signature = _sign(key.signingContent, selfSigningKey); + addSignature(key, + client.userDeviceKeys[client.userID].selfSigningKey, signature); + } + } + } else if (key is CrossSigningKey && key.usage.contains('master')) { + // we are signing someone elses master key + userSigningKey ??= base64 + .decode(await encryption.ssss.getCached(USER_SIGNING_KEY) ?? ''); + if (userSigningKey.isNotEmpty) { + final signature = _sign(key.signingContent, userSigningKey); + addSignature(key, client.userDeviceKeys[client.userID].userSigningKey, + signature); + } + } + } + if (signedKeys.isNotEmpty) { + // post our new keys! + await client.api.uploadKeySignatures(signedKeys); + } + } + + String _sign(String canonicalJson, Uint8List key) { + final keyObj = olm.PkSigning(); + try { + keyObj.init_with_seed(key); + return keyObj.sign(canonicalJson); + } finally { + keyObj.free(); + } + } +} diff --git a/lib/encryption/encryption.dart b/lib/encryption/encryption.dart index 9038b57..940f4fa 100644 --- a/lib/encryption/encryption.dart +++ b/lib/encryption/encryption.dart @@ -24,6 +24,8 @@ import 'package:pedantic/pedantic.dart'; import 'key_manager.dart'; import 'olm_manager.dart'; import 'key_verification_manager.dart'; +import 'cross_signing.dart'; +import 'ssss.dart'; class Encryption { final Client client; @@ -42,15 +44,19 @@ class Encryption { KeyManager keyManager; OlmManager olmManager; KeyVerificationManager keyVerificationManager; + CrossSigning crossSigning; + SSSS ssss; Encryption({ this.client, this.debug, this.enableE2eeRecovery, }) { + ssss = SSSS(this); keyManager = KeyManager(this); olmManager = OlmManager(this); keyVerificationManager = KeyVerificationManager(this); + crossSigning = CrossSigning(this); } Future init(String olmAccount) async { @@ -77,6 +83,24 @@ class Encryption { // do this in the background unawaited(keyVerificationManager.handleToDeviceEvent(event)); } + if (event.type.startsWith('m.secret.')) { + // some ssss thing. We can do this in the background + unawaited(ssss.handleToDeviceEvent(event)); + } + } + + Future handleEventUpdate(EventUpdate update) async { + if (update.type == 'ephemeral') { + return; + } + if (update.eventType.startsWith('m.key.verification.') || + (update.eventType == 'm.room.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 + unawaited(keyVerificationManager.handleEventUpdate(update)); + } } Future decryptToDeviceEvent(ToDeviceEvent event) async { diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index 4782695..d9b2a88 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -27,6 +27,8 @@ import './encryption.dart'; import './utils/session_key.dart'; import './utils/outbound_group_session.dart'; +const MEGOLM_KEY = 'm.megolm_backup.v1'; + class KeyManager { final Encryption encryption; Client get client => encryption.client; @@ -37,7 +39,29 @@ class KeyManager { final Set _loadedOutboundGroupSessions = {}; final Set _requestedSessionIds = {}; - KeyManager(this.encryption); + KeyManager(this.encryption) { + encryption.ssss.setValidator(MEGOLM_KEY, (String secret) async { + final keyObj = olm.PkDecryption(); + try { + final info = await client.api.getRoomKeysBackup(); + if (info.algorithm != RoomKeysAlgorithmType.v1Curve25519AesSha2) { + return false; + } + if (keyObj.init_with_private_key(base64.decode(secret)) == + info.authData['public_key']) { + _requestedSessionIds.clear(); + return true; + } + return false; + } catch (_) { + return false; + } finally { + keyObj.free(); + } + }); + } + + bool get enabled => client.accountData[MEGOLM_KEY] != null; /// clear all cached inbound group sessions. useful for testing void clearInboundGroupSessions() { @@ -296,8 +320,101 @@ class KeyManager { _outboundGroupSessions[roomId] = sess; } + Future isCached() async { + if (!enabled) { + return false; + } + return (await encryption.ssss.getCached(MEGOLM_KEY)) != null; + } + + Future loadFromResponse(RoomKeys keys) async { + if (!(await isCached())) { + return; + } + final privateKey = + base64.decode(await encryption.ssss.getCached(MEGOLM_KEY)); + final decryption = olm.PkDecryption(); + final info = await client.api.getRoomKeysBackup(); + String backupPubKey; + try { + backupPubKey = decryption.init_with_private_key(privateKey); + + if (backupPubKey == null || + info.algorithm != RoomKeysAlgorithmType.v1Curve25519AesSha2 || + info.authData['public_key'] != backupPubKey) { + return; + } + for (final roomEntry in keys.rooms.entries) { + final roomId = roomEntry.key; + for (final sessionEntry in roomEntry.value.sessions.entries) { + final sessionId = sessionEntry.key; + final session = sessionEntry.value; + final firstMessageIndex = session.firstMessageIndex; + final forwardedCount = session.forwardedCount; + final isVerified = session.isVerified; + final sessionData = session.sessionData; + if (firstMessageIndex == null || + forwardedCount == null || + isVerified == null || + !(sessionData is Map)) { + continue; + } + Map decrypted; + try { + decrypted = json.decode(decryption.decrypt(sessionData['ephemeral'], + sessionData['mac'], sessionData['ciphertext'])); + } catch (err) { + print('[LibOlm] Error decrypting room key: ' + err.toString()); + } + if (decrypted != null) { + decrypted['session_id'] = sessionId; + decrypted['room_id'] = roomId; + setInboundGroupSession( + roomId, sessionId, decrypted['sender_key'], decrypted, + forwarded: true); + } + } + } + } finally { + decryption.free(); + } + } + + Future loadSingleKey(String roomId, String sessionId) async { + final info = await client.api.getRoomKeysBackup(); + final ret = + await client.api.getRoomKeysSingleKey(roomId, sessionId, info.version); + final keys = RoomKeys.fromJson({ + 'rooms': { + roomId: { + 'sessions': { + sessionId: ret.toJson(), + }, + }, + }, + }); + await loadFromResponse(keys); + } + /// Request a certain key from another device - Future request(Room room, String sessionId, String senderKey) async { + Future request(Room room, String sessionId, String senderKey, + {bool tryOnlineBackup = true}) async { + if (tryOnlineBackup) { + // let's first check our online key backup store thingy... + var hadPreviously = + getInboundGroupSession(room.id, sessionId, senderKey) != null; + try { + await loadSingleKey(room.id, sessionId); + } catch (err, stacktrace) { + print('[KeyManager] Failed to access online key backup: ' + + err.toString()); + print(stacktrace); + } + if (!hadPreviously && + getInboundGroupSession(room.id, sessionId, senderKey) != null) { + return; // we managed to load the session from online backup, no need to care about it now + } + } // while we just send the to-device event to '*', we still need to save the // devices themself to know where to send the cancel to after receiving a reply final devices = await room.getUserDeviceKeys(); @@ -336,22 +453,27 @@ class KeyManager { } if (event.content['action'] == 'request') { // we are *receiving* a request + print('[KeyManager] Received key sharing request...'); if (!event.content.containsKey('body')) { + print('[KeyManager] No body, doing nothing'); return; // no body } if (!client.userDeviceKeys.containsKey(event.sender) || !client.userDeviceKeys[event.sender].deviceKeys .containsKey(event.content['requesting_device_id'])) { + print('[KeyManager] Device not found, doing nothing'); return; // device not found } final device = client.userDeviceKeys[event.sender] .deviceKeys[event.content['requesting_device_id']]; if (device.userId == client.userID && device.deviceId == client.deviceID) { + print('[KeyManager] Request is by ourself, ignoring'); return; // ignore requests by ourself } final room = client.getRoomById(event.content['body']['room_id']); if (room == null) { + print('[KeyManager] Unknown room, ignoring'); return; // unknown room } final sessionId = event.content['body']['session_id']; @@ -359,6 +481,7 @@ class KeyManager { // okay, let's see if we have this session at all if ((await loadInboundGroupSession(room.id, sessionId, senderKey)) == null) { + print('[KeyManager] Unknown session, ignoring'); return; // we don't have this session anyways } final request = KeyManagerKeyShareRequest( @@ -369,6 +492,7 @@ class KeyManager { senderKey: senderKey, ); if (incomingShareRequests.containsKey(request.requestId)) { + print('[KeyManager] Already processed this request, ignoring'); return; // we don't want to process one and the same request multiple times } incomingShareRequests[request.requestId] = request; @@ -377,9 +501,11 @@ class KeyManager { if (device.userId == client.userID && device.verified && !device.blocked) { + print('[KeyManager] All checks out, forwarding key...'); // alright, we can forward the key await roomKeyRequest.forwardKey(); } else { + print('[KeyManager] Asking client, if the key should be forwarded'); client.onRoomKeyRequest .add(roomKeyRequest); // let the client handle this } diff --git a/lib/encryption/key_verification_manager.dart b/lib/encryption/key_verification_manager.dart index f8720f4..de82074 100644 --- a/lib/encryption/key_verification_manager.dart +++ b/lib/encryption/key_verification_manager.dart @@ -29,6 +29,7 @@ class KeyVerificationManager { final Map _requests = {}; Future cleanup() async { + final Set entriesToDispose = {}; for (final entry in _requests.entries) { var dispose = entry.value.canceled || entry.value.state == KeyVerificationState.done || @@ -38,9 +39,12 @@ class KeyVerificationManager { } if (dispose) { entry.value.dispose(); - _requests.remove(entry.key); + entriesToDispose.add(entry.key); } } + for (final k in entriesToDispose) { + _requests.remove(k); + } } void addRequest(KeyVerification request) { @@ -51,7 +55,8 @@ class KeyVerificationManager { } Future handleToDeviceEvent(ToDeviceEvent event) async { - if (!event.type.startsWith('m.key.verification')) { + if (!event.type.startsWith('m.key.verification') || + client.verificationMethods.isEmpty) { return; } // we have key verification going on! @@ -75,6 +80,54 @@ class KeyVerificationManager { } } + Future handleEventUpdate(EventUpdate update) async { + final event = update.content; + final type = event['type'].startsWith('m.key.verification.') + ? event['type'] + : event['content']['msgtype']; + if (type == null || + !type.startsWith('m.key.verification.') || + client.verificationMethods.isEmpty) { + return; + } + if (type == 'm.key.verification.request') { + event['content']['timestamp'] = event['origin_server_ts']; + } + + final transactionId = + KeyVerification.getTransactionId(event['content']) ?? event['event_id']; + + if (_requests.containsKey(transactionId)) { + final req = _requests[transactionId]; + final otherDeviceId = event['content']['from_device']; + if (event['sender'] != client.userID) { + await req.handlePayload(type, event['content'], event['event_id']); + } else if (event['sender'] == client.userID && + otherDeviceId != null && + otherDeviceId != client.deviceID) { + // okay, another of our devices answered + req.otherDeviceAccepted(); + req.dispose(); + _requests.remove(transactionId); + } + } else if (event['sender'] != client.userID) { + final room = client.getRoomById(update.roomID) ?? + Room(id: update.roomID, client: client); + final newKeyRequest = KeyVerification( + encryption: encryption, userId: event['sender'], room: room); + await newKeyRequest.handlePayload( + type, event['content'], event['event_id']); + if (newKeyRequest.state != KeyVerificationState.askAccept) { + // something went wrong, let's just dispose the request + newKeyRequest.dispose(); + } else { + // new request! Let's notify it and stuff + _requests[transactionId] = newKeyRequest; + client.onKeyVerificationRequest.add(newKeyRequest); + } + } + } + void dispose() { for (final req in _requests.values) { req.dispose(); diff --git a/lib/encryption/olm_manager.dart b/lib/encryption/olm_manager.dart index 43871c0..b08ab1b 100644 --- a/lib/encryption/olm_manager.dart +++ b/lib/encryption/olm_manager.dart @@ -96,6 +96,10 @@ class OlmManager { return payload; } + String signString(String s) { + return _olmAccount.sign(s); + } + /// Checks the signature of a signed json object. bool checkJsonSignature(String key, Map signedJson, String userId, String deviceId) { diff --git a/lib/encryption/ssss.dart b/lib/encryption/ssss.dart new file mode 100644 index 0000000..9349a14 --- /dev/null +++ b/lib/encryption/ssss.dart @@ -0,0 +1,483 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 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:typed_data'; +import 'dart:convert'; + +import 'package:encrypt/encrypt.dart'; +import 'package:crypto/crypto.dart'; +import 'package:base58check/base58.dart'; +import 'package:password_hash/password_hash.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/matrix_api.dart'; + +import 'encryption.dart'; + +const CACHE_TYPES = [ + 'm.cross_signing.self_signing', + 'm.cross_signing.user_signing', + 'm.megolm_backup.v1' +]; +const ZERO_STR = + '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'; +const BASE58_ALPHABET = + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const base58 = Base58Codec(BASE58_ALPHABET); +const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; +const OLM_PRIVATE_KEY_LENGTH = 32; // TODO: fetch from dart-olm + +class SSSS { + final Encryption encryption; + Client get client => encryption.client; + final pendingShareRequests = {}; + final _validators = Function(String)>{}; + SSSS(this.encryption); + + static _DerivedKeys deriveKeys(Uint8List key, String name) { + final zerosalt = Uint8List(8); + final prk = Hmac(sha256, zerosalt).convert(key); + final b = Uint8List(1); + b[0] = 1; + final aesKey = Hmac(sha256, prk.bytes).convert(utf8.encode(name) + b); + b[0] = 2; + final hmacKey = + Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b); + return _DerivedKeys(aesKey: aesKey.bytes, hmacKey: hmacKey.bytes); + } + + static _Encrypted encryptAes(String data, Uint8List key, String name, + [String ivStr]) { + Uint8List iv; + if (ivStr != null) { + iv = base64.decode(ivStr); + } else { + iv = Uint8List.fromList(SecureRandom(16).bytes); + } + // we need to clear bit 63 of the IV + iv[8] &= 0x7f; + + final keys = deriveKeys(key, name); + + final plain = Uint8List.fromList(utf8.encode(data)); + final ciphertext = AES(Key(keys.aesKey), mode: AESMode.ctr, padding: null) + .encrypt(plain, iv: IV(iv)) + .bytes; + + final hmac = Hmac(sha256, keys.hmacKey).convert(ciphertext); + + return _Encrypted( + iv: base64.encode(iv), + ciphertext: base64.encode(ciphertext), + mac: base64.encode(hmac.bytes)); + } + + static String decryptAes(_Encrypted data, Uint8List key, String name) { + final keys = deriveKeys(key, name); + final cipher = base64.decode(data.ciphertext); + final hmac = base64 + .encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes) + .replaceAll(RegExp(r'=+$'), ''); + if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) { + throw 'Bad MAC'; + } + final decipher = AES(Key(keys.aesKey), mode: AESMode.ctr, padding: null) + .decrypt(Encrypted(cipher), iv: IV(base64.decode(data.iv))); + return String.fromCharCodes(decipher); + } + + static Uint8List decodeRecoveryKey(String recoveryKey) { + final result = base58.decode(recoveryKey.replaceAll(' ', '')); + + var parity = 0; + for (final b in result) { + parity ^= b; + } + if (parity != 0) { + throw 'Incorrect parity'; + } + + for (var i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; i++) { + if (result[i] != OLM_RECOVERY_KEY_PREFIX[i]) { + throw 'Incorrect prefix'; + } + } + + if (result.length != + OLM_RECOVERY_KEY_PREFIX.length + OLM_PRIVATE_KEY_LENGTH + 1) { + throw 'Incorrect length'; + } + + return Uint8List.fromList(result.sublist(OLM_RECOVERY_KEY_PREFIX.length, + OLM_RECOVERY_KEY_PREFIX.length + OLM_PRIVATE_KEY_LENGTH)); + } + + static Uint8List keyFromPassphrase(String passphrase, _PassphraseInfo info) { + if (info.algorithm != 'm.pbkdf2') { + throw 'Unknown algorithm'; + } + final generator = PBKDF2(hashAlgorithm: sha512); + return Uint8List.fromList(generator.generateKey(passphrase, info.salt, + info.iterations, info.bits != null ? info.bits / 8 : 32)); + } + + void setValidator(String type, Future Function(String) validator) { + _validators[type] = validator; + } + + String get defaultKeyId { + final keyData = client.accountData['m.secret_storage.default_key']; + if (keyData == null || !(keyData.content['key'] is String)) { + return null; + } + return keyData.content['key']; + } + + BasicEvent getKey(String keyId) { + return client.accountData['m.secret_storage.key.${keyId}']; + } + + bool checkKey(Uint8List key, BasicEvent keyData) { + final info = keyData.content; + if (info['algorithm'] == 'm.secret_storage.v1.aes-hmac-sha2') { + if ((info['mac'] is String) && (info['iv'] is String)) { + final encrypted = encryptAes(ZERO_STR, key, '', info['iv']); + return info['mac'].replaceAll(RegExp(r'=+$'), '') == + encrypted.mac.replaceAll(RegExp(r'=+$'), ''); + } else { + // no real information about the key, assume it is valid + return true; + } + } else { + throw 'Unknown Algorithm'; + } + } + + Future getCached(String type) async { + if (client.database == null) { + return null; + } + final ret = await client.database.getSSSSCache(client.id, type); + if (ret == null) { + return null; + } + // check if it is still valid + final keys = keyIdsFromType(type); + if (keys.contains(ret.keyId) && + client.accountData[type].content['encrypted'][ret.keyId] + ['ciphertext'] == + ret.ciphertext) { + return ret.content; + } + return null; + } + + Future getStored(String type, String keyId, Uint8List key) async { + final secretInfo = client.accountData[type]; + if (secretInfo == null) { + throw 'Not found'; + } + if (!(secretInfo.content['encrypted'] is Map)) { + throw 'Content is not encrypted'; + } + if (!(secretInfo.content['encrypted'][keyId] is Map)) { + throw 'Wrong / unknown key'; + } + final enc = secretInfo.content['encrypted'][keyId]; + final encryptInfo = _Encrypted( + iv: enc['iv'], ciphertext: enc['ciphertext'], mac: enc['mac']); + final decrypted = decryptAes(encryptInfo, key, type); + if (CACHE_TYPES.contains(type) && client.database != null) { + // cache the thing + await client.database + .storeSSSSCache(client.id, type, keyId, enc['ciphertext'], decrypted); + } + return decrypted; + } + + Future store( + String type, String secret, String keyId, Uint8List key) async { + final encrypted = encryptAes(secret, key, type); + final content = { + 'encrypted': {}, + }; + content['encrypted'][keyId] = { + 'iv': encrypted.iv, + 'ciphertext': encrypted.ciphertext, + 'mac': encrypted.mac, + }; + // store the thing in your account data + await client.api.setAccountData(client.userID, type, content); + if (CACHE_TYPES.contains(type) && client.database != null) { + // cache the thing + await client.database + .storeSSSSCache(client.id, type, keyId, encrypted.ciphertext, secret); + } + } + + Future maybeCacheAll(String keyId, Uint8List key) async { + for (final type in CACHE_TYPES) { + final secret = await getCached(type); + if (secret == null) { + try { + await getStored(type, keyId, key); + } catch (_) { + // the entry wasn't stored, just ignore it + } + } + } + } + + Future maybeRequestAll(List devices) async { + for (final type in CACHE_TYPES) { + final secret = await getCached(type); + if (secret == null) { + await request(type, devices); + } + } + } + + Future request(String type, List devices) async { + // only send to own, verified devices + print('[SSSS] Requesting type ${type}...'); + devices.removeWhere((DeviceKeys d) => + d.userId != client.userID || + !d.verified || + d.blocked || + d.deviceId == client.deviceID); + if (devices.isEmpty) { + print('[SSSS] Warn: No devices'); + return; + } + final requestId = client.generateUniqueTransactionId(); + final request = _ShareRequest( + requestId: requestId, + type: type, + devices: devices, + ); + pendingShareRequests[requestId] = request; + await client.sendToDevice(devices, 'm.secret.request', { + 'action': 'request', + 'requesting_device_id': client.deviceID, + 'request_id': requestId, + 'name': type, + }); + } + + Future handleToDeviceEvent(ToDeviceEvent event) async { + if (event.type == 'm.secret.request') { + // got a request to share a secret + print('[SSSS] Received sharing request...'); + if (event.sender != client.userID || + !client.userDeviceKeys.containsKey(client.userID)) { + print('[SSSS] Not sent by us'); + return; // we aren't asking for it ourselves, so ignore + } + if (event.content['action'] != 'request') { + print('[SSSS] it is actually a cancelation'); + return; // not actually requesting, so ignore + } + final device = client.userDeviceKeys[client.userID] + .deviceKeys[event.content['requesting_device_id']]; + if (device == null || !device.verified || device.blocked) { + print('[SSSS] Unknown / unverified devices, ignoring'); + return; // nope....unknown or untrusted device + } + // alright, all seems fine...let's check if we actually have the secret they are asking for + final type = event.content['name']; + final secret = await getCached(type); + if (secret == null) { + print('[SSSS] We don\'t have the secret for ${type} ourself, ignoring'); + return; // seems like we don't have this, either + } + // okay, all checks out...time to share this secret! + print('[SSSS] Replying with secret for ${type}'); + await client.sendToDevice( + [device], + 'm.secret.send', + { + 'request_id': event.content['request_id'], + 'secret': secret, + }); + } else if (event.type == 'm.secret.send') { + // receiving a secret we asked for + print('[SSSS] Received shared secret...'); + if (event.sender != client.userID || + !pendingShareRequests.containsKey(event.content['request_id']) || + event.encryptedContent == null) { + print('[SSSS] Not by us or unknown request'); + return; // we have no idea what we just received + } + final request = pendingShareRequests[event.content['request_id']]; + // alright, as we received a known request id, let's check if the sender is valid + final device = request.devices.firstWhere( + (d) => + d.userId == event.sender && + d.curve25519Key == event.encryptedContent['sender_key'], + orElse: () => null); + if (device == null) { + print('[SSSS] Someone else replied?'); + return; // someone replied whom we didn't send the share request to + } + final secret = event.content['secret']; + if (!(event.content['secret'] is String)) { + print('[SSSS] Secret wasn\'t a string'); + return; // the secret wasn't a string....wut? + } + // let's validate if the secret is, well, valid + if (_validators.containsKey(request.type) && + !(await _validators[request.type](secret))) { + print('[SSSS] The received secret was invalid'); + return; // didn't pass the validator + } + pendingShareRequests.remove(request.requestId); + if (request.start.add(Duration(minutes: 15)).isBefore(DateTime.now())) { + print('[SSSS] Request is too far in the past'); + return; // our request is more than 15min in the past...better not trust it anymore + } + print('[SSSS] Secret for type ${request.type} is ok, storing it'); + if (client.database != null) { + final keyId = keyIdFromType(request.type); + if (keyId != null) { + final ciphertext = client.accountData[request.type] + .content['encrypted'][keyId]['ciphertext']; + await client.database.storeSSSSCache( + client.id, request.type, keyId, ciphertext, secret); + } + } + } + } + + Set keyIdsFromType(String type) { + final data = client.accountData[type]; + if (data == null) { + return null; + } + if (data.content['encrypted'] is Map) { + final Set keys = {}; + for (final key in data.content['encrypted'].keys) { + keys.add(key); + } + return keys; + } + return null; + } + + String keyIdFromType(String type) { + final keys = keyIdsFromType(type); + if (keys == null || keys.isEmpty) { + return null; + } + if (keys.contains(defaultKeyId)) { + return defaultKeyId; + } + return keys.first; + } + + OpenSSSS open([String identifier]) { + identifier ??= defaultKeyId; + if (identifier == null) { + throw 'Dont know what to open'; + } + final keyToOpen = keyIdFromType(identifier) ?? identifier; + if (keyToOpen == null) { + throw 'No key found to open'; + } + final key = getKey(keyToOpen); + if (key == null) { + throw 'Unknown key to open'; + } + return OpenSSSS(ssss: this, keyId: keyToOpen, keyData: key); + } +} + +class _ShareRequest { + final String requestId; + final String type; + final List devices; + final DateTime start; + + _ShareRequest({this.requestId, this.type, this.devices}) + : start = DateTime.now(); +} + +class _Encrypted { + final String iv; + final String ciphertext; + final String mac; + + _Encrypted({this.iv, this.ciphertext, this.mac}); +} + +class _DerivedKeys { + final Uint8List aesKey; + final Uint8List hmacKey; + + _DerivedKeys({this.aesKey, this.hmacKey}); +} + +class _PassphraseInfo { + final String algorithm; + final String salt; + final int iterations; + final int bits; + + _PassphraseInfo({this.algorithm, this.salt, this.iterations, this.bits}); +} + +class OpenSSSS { + final SSSS ssss; + final String keyId; + final BasicEvent keyData; + OpenSSSS({this.ssss, this.keyId, this.keyData}); + Uint8List privateKey; + + bool get isUnlocked => privateKey != null; + + void unlock({String passphrase, String recoveryKey}) { + if (passphrase != null) { + privateKey = SSSS.keyFromPassphrase( + passphrase, + _PassphraseInfo( + algorithm: keyData.content['passphrase']['algorithm'], + salt: keyData.content['passphrase']['salt'], + iterations: keyData.content['passphrase']['iterations'], + bits: keyData.content['passphrase']['bits'])); + } else if (recoveryKey != null) { + privateKey = SSSS.decodeRecoveryKey(recoveryKey); + } else { + throw 'Nothing specified'; + } + // verify the validity of the key + if (!ssss.checkKey(privateKey, keyData)) { + privateKey = null; + throw 'Inalid key'; + } + } + + Future getStored(String type) async { + return await ssss.getStored(type, keyId, privateKey); + } + + Future store(String type, String secret) async { + await ssss.store(type, secret, keyId, privateKey); + } + + Future maybeCacheAll() async { + await ssss.maybeCacheAll(keyId, privateKey); + } +} diff --git a/lib/encryption/utils/key_verification.dart b/lib/encryption/utils/key_verification.dart index 3698477..90a2479 100644 --- a/lib/encryption/utils/key_verification.dart +++ b/lib/encryption/utils/key_verification.dart @@ -16,9 +16,10 @@ * along with this program. If not, see . */ +import 'dart:async'; import 'dart:typed_data'; -import 'package:random_string/random_string.dart'; import 'package:canonical_json/canonical_json.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:olm/olm.dart' as olm; import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/matrix_api.dart'; @@ -63,6 +64,7 @@ import '../encryption.dart'; enum KeyVerificationState { askAccept, + askSSSS, waitingAccept, askSas, waitingSas, @@ -70,6 +72,8 @@ enum KeyVerificationState { error } +enum KeyVerificationMethod { emoji, numbers } + List _intersect(List a, List b) { if (b == null || a == null) { return []; @@ -103,11 +107,9 @@ List _bytesToInt(Uint8List bytes, int totalBits) { return ret; } -final VERIFICATION_METHODS = [_KeyVerificationMethodSas.type]; - _KeyVerificationMethod _makeVerificationMethod( String type, KeyVerification request) { - if (type == _KeyVerificationMethodSas.type) { + if (type == 'm.sas.v1') { return _KeyVerificationMethodSas(request: request); } throw 'Unkown method type'; @@ -126,6 +128,8 @@ class KeyVerification { _KeyVerificationMethod method; List possibleMethods; Map startPaylaod; + String _nextAction; + List _verifiedDevices; DateTime lastActivity; String lastStep; @@ -157,22 +161,44 @@ class KeyVerification { : null); } - Future start() async { - if (room == null) { - transactionId = randomString(512); + List get knownVerificationMethods { + final methods = []; + if (client.verificationMethods.contains(KeyVerificationMethod.numbers) || + client.verificationMethods.contains(KeyVerificationMethod.emoji)) { + methods.add('m.sas.v1'); } + return methods; + } + + Future sendStart() async { await send('m.key.verification.request', { - 'methods': VERIFICATION_METHODS, - 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'methods': knownVerificationMethods, + if (room == null) 'timestamp': DateTime.now().millisecondsSinceEpoch, }); startedVerification = true; setState(KeyVerificationState.waitingAccept); + lastActivity = DateTime.now(); + } + + Future start() async { + if (room == null) { + transactionId = client.generateUniqueTransactionId(); + } + if (encryption.crossSigning.enabled && + !(await encryption.crossSigning.isCached()) && + !client.isUnknownSession) { + setState(KeyVerificationState.askSSSS); + _nextAction = 'request'; + } else { + await sendStart(); + } } Future handlePayload(String type, Map payload, [String eventId]) async { print('[Key Verification] Received type ${type}: ' + payload.toString()); try { + var thisLastStep = lastStep; switch (type) { case 'm.key.verification.request': _deviceId ??= payload['from_device']; @@ -188,7 +214,7 @@ class KeyVerification { } // verify it has a method we can use possibleMethods = - _intersect(VERIFICATION_METHODS, payload['methods']); + _intersect(knownVerificationMethods, payload['methods']); if (possibleMethods.isEmpty) { // reject it outright await cancel('m.unknown_method'); @@ -197,13 +223,17 @@ class KeyVerification { setState(KeyVerificationState.askAccept); break; case 'm.key.verification.ready': + _deviceId ??= payload['from_device']; possibleMethods = - _intersect(VERIFICATION_METHODS, payload['methods']); + _intersect(knownVerificationMethods, payload['methods']); if (possibleMethods.isEmpty) { // reject it outright await cancel('m.unknown_method'); return; } + // as both parties can send a start, the last step being "ready" is race-condition prone + // as such, we better set it *before* we send our start + lastStep = type; // TODO: Pick method? method = _makeVerificationMethod(possibleMethods.first, this); await method.sendStart(); @@ -212,10 +242,33 @@ class KeyVerification { case 'm.key.verification.start': _deviceId ??= payload['from_device']; transactionId ??= eventId ?? payload['transaction_id']; + if (method != null) { + // the other side sent us a start, even though we already sent one + if (payload['method'] == method.type) { + // same method. Determine priority + final ourEntry = '${client.userID}|${client.deviceID}'; + final entries = [ourEntry, '${userId}|${deviceId}']; + entries.sort(); + if (entries.first == ourEntry) { + // our start won, nothing to do + return; + } else { + // the other start won, let's hand off + startedVerification = false; // it is now as if they started + thisLastStep = lastStep = + 'm.key.verification.request'; // we fake the last step + method.dispose(); // in case anything got created already + } + } else { + // methods don't match up, let's cancel this + await cancel('m.unexpected_message'); + return; + } + } if (!(await verifyLastStep(['m.key.verification.request', null]))) { return; // abort } - if (!VERIFICATION_METHODS.contains(payload['method'])) { + if (!knownVerificationMethods.contains(payload['method'])) { await cancel('m.unknown_method'); return; } @@ -228,6 +281,7 @@ class KeyVerification { startPaylaod = payload; setState(KeyVerificationState.askAccept); } else { + print('handling start in method.....'); await method.handlePayload(type, payload); } break; @@ -244,7 +298,9 @@ class KeyVerification { await method.handlePayload(type, payload); break; } - lastStep = type; + if (lastStep == thisLastStep) { + lastStep = type; + } } catch (err, stacktrace) { print('[Key Verification] An error occured: ' + err.toString()); print(stacktrace); @@ -254,6 +310,36 @@ class KeyVerification { } } + void otherDeviceAccepted() { + canceled = true; + canceledCode = 'm.accepted'; + canceledReason = 'm.accepted'; + setState(KeyVerificationState.error); + } + + Future openSSSS( + {String passphrase, String recoveryKey, bool skip = false}) async { + final next = () { + if (_nextAction == 'request') { + sendStart(); + } else if (_nextAction == 'done') { + if (_verifiedDevices != null) { + // and now let's sign them all in the background + encryption.crossSigning.sign(_verifiedDevices); + } + setState(KeyVerificationState.done); + } + }; + if (skip) { + next(); + return; + } + final handle = encryption.ssss.open('m.cross_signing.user_signing'); + await handle.unlock(passphrase: passphrase, recoveryKey: recoveryKey); + await handle.maybeCacheAll(); + next(); + } + /// called when the user accepts an incoming verification Future acceptVerification() async { if (!(await verifyLastStep( @@ -318,9 +404,29 @@ class KeyVerification { return []; } + Future maybeRequestSSSSSecrets([int i = 0]) async { + final requestInterval = [10, 60]; + if ((!encryption.crossSigning.enabled || + (encryption.crossSigning.enabled && + (await encryption.crossSigning.isCached()))) && + (!encryption.keyManager.enabled || + (encryption.keyManager.enabled && + (await encryption.keyManager.isCached())))) { + // no need to request cache, we already have it + return; + } + unawaited(encryption.ssss + .maybeRequestAll(_verifiedDevices.whereType().toList())); + if (requestInterval.length <= i) { + return; + } + Timer(Duration(seconds: requestInterval[i]), + () => maybeRequestSSSSSecrets(i + 1)); + } + Future verifyKeys(Map keys, - Future Function(String, DeviceKeys) verifier) async { - final verifiedDevices = []; + Future Function(String, SignableKey) verifier) async { + _verifiedDevices = []; if (!client.userDeviceKeys.containsKey(userId)) { await cancel('m.key_mismatch'); @@ -330,23 +436,48 @@ class KeyVerification { final keyId = entry.key; final verifyDeviceId = keyId.substring('ed25519:'.length); final keyInfo = entry.value; - if (client.userDeviceKeys[userId].deviceKeys - .containsKey(verifyDeviceId)) { - if (!(await verifier(keyInfo, - client.userDeviceKeys[userId].deviceKeys[verifyDeviceId]))) { + final key = client.userDeviceKeys[userId].getKey(verifyDeviceId); + if (key != null) { + if (!(await verifier(keyInfo, key))) { await cancel('m.key_mismatch'); return; } - verifiedDevices.add(verifyDeviceId); - } else { - // TODO: we would check here if what we are verifying is actually a - // cross-signing key and not a "normal" device key + _verifiedDevices.add(key); } } // okay, we reached this far, so all the devices are verified! - for (final verifyDeviceId in verifiedDevices) { - await client.userDeviceKeys[userId].deviceKeys[verifyDeviceId] - .setVerified(true, client); + var verifiedMasterKey = false; + final wasUnknownSession = client.isUnknownSession; + for (final key in _verifiedDevices) { + await key.setVerified( + true, false); // we don't want to sign the keys juuuust yet + if (key is CrossSigningKey && key.usage.contains('master')) { + verifiedMasterKey = true; + } + } + if (verifiedMasterKey && userId == client.userID) { + // it was our own master key, let's request the cross signing keys + // we do it in the background, thus no await needed here + unawaited(maybeRequestSSSSSecrets()); + } + await send('m.key.verification.done', {}); + + var askingSSSS = false; + if (encryption.crossSigning.enabled && + encryption.crossSigning.signable(_verifiedDevices)) { + // these keys can be signed! Let's do so + if (await encryption.crossSigning.isCached()) { + // and now let's sign them all in the background + unawaited(encryption.crossSigning.sign(_verifiedDevices)); + } else if (!wasUnknownSession) { + askingSSSS = true; + } + } + if (askingSSSS) { + setState(KeyVerificationState.askSSSS); + _nextAction = 'done'; + } else { + setState(KeyVerificationState.done); } } @@ -439,6 +570,9 @@ abstract class _KeyVerificationMethod { return false; } + String _type; + String get type => _type; + Future sendStart(); void dispose() {} } @@ -446,13 +580,13 @@ abstract class _KeyVerificationMethod { const KNOWN_KEY_AGREEMENT_PROTOCOLS = ['curve25519-hkdf-sha256', 'curve25519']; const KNOWN_HASHES = ['sha256']; const KNOWN_MESSAGE_AUTHENTIFICATION_CODES = ['hkdf-hmac-sha256']; -const KNOWN_AUTHENTICATION_TYPES = ['emoji', 'decimal']; class _KeyVerificationMethodSas extends _KeyVerificationMethod { _KeyVerificationMethodSas({KeyVerification request}) : super(request: request); - static String type = 'm.sas.v1'; + @override + final _type = 'm.sas.v1'; String keyAgreementProtocol; String hash; @@ -469,6 +603,19 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { sas?.free(); } + List get knownAuthentificationTypes { + final types = []; + if (request.client.verificationMethods + .contains(KeyVerificationMethod.emoji)) { + types.add('emoji'); + } + if (request.client.verificationMethods + .contains(KeyVerificationMethod.numbers)) { + types.add('decimal'); + } + return types; + } + @override Future handlePayload(String type, Map payload) async { try { @@ -550,7 +697,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { 'key_agreement_protocols': KNOWN_KEY_AGREEMENT_PROTOCOLS, 'hashes': KNOWN_HASHES, 'message_authentication_codes': KNOWN_MESSAGE_AUTHENTIFICATION_CODES, - 'short_authentication_string': KNOWN_AUTHENTICATION_TYPES, + 'short_authentication_string': knownAuthentificationTypes, }; request.makePayload(payload); // We just store the canonical json in here for later verification @@ -582,7 +729,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { } messageAuthenticationCode = possibleMessageAuthenticationCodes.first; final possibleAuthenticationTypes = _intersect( - KNOWN_AUTHENTICATION_TYPES, payload['short_authentication_string']); + knownAuthentificationTypes, payload['short_authentication_string']); if (possibleAuthenticationTypes.isEmpty) { return false; } @@ -620,7 +767,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { } messageAuthenticationCode = payload['message_authentication_code']; final possibleAuthenticationTypes = _intersect( - KNOWN_AUTHENTICATION_TYPES, payload['short_authentication_string']); + knownAuthentificationTypes, payload['short_authentication_string']); if (possibleAuthenticationTypes.isEmpty) { return false; } @@ -690,6 +837,17 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { _calculateMac(encryption.fingerprintKey, baseInfo + deviceKeyId); keyList.add(deviceKeyId); + final masterKey = client.userDeviceKeys.containsKey(client.userID) + ? client.userDeviceKeys[client.userID].masterKey + : null; + if (masterKey != null && masterKey.verified) { + // we have our own master key verified, let's send it! + final masterKeyId = 'ed25519:${masterKey.publicKey}'; + mac[masterKeyId] = + _calculateMac(masterKey.publicKey, baseInfo + masterKeyId); + keyList.add(masterKeyId); + } + keyList.sort(); final keys = _calculateMac(keyList.join(','), baseInfo + 'KEY_IDS'); await request.send('m.key.verification.mac', { @@ -725,15 +883,10 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { mac[entry.key] = entry.value; } } - await request.verifyKeys(mac, (String mac, DeviceKeys device) async { + await request.verifyKeys(mac, (String mac, SignableKey key) async { return mac == - _calculateMac( - device.ed25519Key, baseInfo + 'ed25519:' + device.deviceId); + _calculateMac(key.ed25519Key, baseInfo + 'ed25519:' + key.identifier); }); - await request.send('m.key.verification.done', {}); - if (request.state != KeyVerificationState.error) { - request.setState(KeyVerificationState.done); - } } String _makeCommitment(String pubKey, String canonicalJson) { diff --git a/lib/matrix_api.dart b/lib/matrix_api.dart index d364cf8..be120a3 100644 --- a/lib/matrix_api.dart +++ b/lib/matrix_api.dart @@ -31,8 +31,8 @@ export 'package:famedlysdk/matrix_api/model/filter.dart'; export 'package:famedlysdk/matrix_api/model/keys_query_response.dart'; export 'package:famedlysdk/matrix_api/model/login_response.dart'; export 'package:famedlysdk/matrix_api/model/login_types.dart'; -export 'package:famedlysdk/matrix_api/model/matrix_device_keys.dart'; export 'package:famedlysdk/matrix_api/model/matrix_exception.dart'; +export 'package:famedlysdk/matrix_api/model/matrix_keys.dart'; export 'package:famedlysdk/matrix_api/model/message_types.dart'; export 'package:famedlysdk/matrix_api/model/presence_content.dart'; export 'package:famedlysdk/matrix_api/model/notifications_query_response.dart'; @@ -46,6 +46,8 @@ export 'package:famedlysdk/matrix_api/model/push_rule_set.dart'; export 'package:famedlysdk/matrix_api/model/pusher.dart'; export 'package:famedlysdk/matrix_api/model/request_token_response.dart'; export 'package:famedlysdk/matrix_api/model/room_alias_informations.dart'; +export 'package:famedlysdk/matrix_api/model/room_keys_info.dart'; +export 'package:famedlysdk/matrix_api/model/room_keys_keys.dart'; export 'package:famedlysdk/matrix_api/model/room_summary.dart'; export 'package:famedlysdk/matrix_api/model/server_capabilities.dart'; export 'package:famedlysdk/matrix_api/model/stripped_state_event.dart'; @@ -58,6 +60,7 @@ export 'package:famedlysdk/matrix_api/model/third_party_location.dart'; export 'package:famedlysdk/matrix_api/model/third_party_user.dart'; export 'package:famedlysdk/matrix_api/model/timeline_history_response.dart'; export 'package:famedlysdk/matrix_api/model/turn_server_credentials.dart'; +export 'package:famedlysdk/matrix_api/model/upload_key_signatures_response.dart'; export 'package:famedlysdk/matrix_api/model/user_search_result.dart'; export 'package:famedlysdk/matrix_api/model/well_known_informations.dart'; export 'package:famedlysdk/matrix_api/model/who_is_info.dart'; diff --git a/lib/matrix_api/matrix_api.dart b/lib/matrix_api/matrix_api.dart index 80effc0..d0d716d 100644 --- a/lib/matrix_api/matrix_api.dart +++ b/lib/matrix_api/matrix_api.dart @@ -36,8 +36,8 @@ import 'package:mime_type/mime_type.dart'; import 'package:moor/moor.dart'; import 'model/device.dart'; -import 'model/matrix_device_keys.dart'; import 'model/matrix_event.dart'; +import 'model/matrix_keys.dart'; import 'model/event_context.dart'; import 'model/events_sync_update.dart'; import 'model/login_response.dart'; @@ -49,11 +49,14 @@ import 'model/public_rooms_response.dart'; import 'model/push_rule_set.dart'; import 'model/pusher.dart'; import 'model/room_alias_informations.dart'; +import 'model/room_keys_info.dart'; +import 'model/room_keys_keys.dart'; import 'model/supported_protocol.dart'; import 'model/tag.dart'; import 'model/third_party_identifier.dart'; import 'model/third_party_user.dart'; import 'model/turn_server_credentials.dart'; +import 'model/upload_key_signatures_response.dart'; import 'model/well_known_informations.dart'; import 'model/who_is_info.dart'; @@ -1503,6 +1506,55 @@ class MatrixApi { return DeviceListsUpdate.fromJson(response); } + /// Uploads your own cross-signing keys. + /// https://github.com/matrix-org/matrix-doc/pull/2536 + Future uploadDeviceSigningKeys({ + MatrixCrossSigningKey masterKey, + MatrixCrossSigningKey selfSigningKey, + MatrixCrossSigningKey userSigningKey, + }) async { + await request( + RequestType.POST, + '/client/r0/keys/device_signing/upload', + data: { + 'master_key': masterKey.toJson(), + 'self_signing_key': selfSigningKey.toJson(), + 'user_signing_key': userSigningKey.toJson(), + }, + ); + } + + /// Uploads new signatures of keys + /// https://github.com/matrix-org/matrix-doc/pull/2536 + Future uploadKeySignatures( + List keys) async { + final payload = {}; + for (final key in keys) { + if (key.identifier == null || + key.signatures == null || + key.signatures.isEmpty) { + continue; + } + if (!payload.containsKey(key.userId)) { + payload[key.userId] = {}; + } + if (payload[key.userId].containsKey(key.identifier)) { + // we need to merge signature objects + payload[key.userId][key.identifier]['signatures'] + .addAll(key.signatures); + } else { + // we can just add signatures + payload[key.userId][key.identifier] = key.toJson(); + } + } + final response = await request( + RequestType.POST, + '/client/r0/keys/signatures/upload', + data: payload, + ); + return UploadKeySignaturesResponse.fromJson(response); + } + /// Gets all currently active pushers for the authenticated user. /// https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-pushers Future> requestPushers() async { @@ -1986,4 +2038,156 @@ class MatrixApi { ); return; } + + /// Create room keys backup + /// https://matrix.org/docs/spec/client_server/unstable#post-matrix-client-r0-room-keys-version + Future createRoomKeysBackup( + RoomKeysAlgorithmType algorithm, Map authData) async { + final ret = await request( + RequestType.POST, + '/client/unstable/room_keys/version', + data: { + 'algorithm': algorithm.algorithmString, + 'auth_data': authData, + }, + ); + return ret['version']; + } + + /// Gets a room key backup + /// https://matrix.org/docs/spec/client_server/unstable#get-matrix-client-r0-room-keys-version + Future getRoomKeysBackup([String version]) async { + var url = '/client/unstable/room_keys/version'; + if (version != null) { + url += '/${Uri.encodeComponent(version)}'; + } + final ret = await request( + RequestType.GET, + url, + ); + return RoomKeysVersionResponse.fromJson(ret); + } + + /// Updates a room key backup + /// https://matrix.org/docs/spec/client_server/unstable#put-matrix-client-r0-room-keys-version-version + Future updateRoomKeysBackup(String version, + RoomKeysAlgorithmType algorithm, Map authData) async { + await request( + RequestType.PUT, + '/client/unstable/room_keys/version/${Uri.encodeComponent(version)}', + data: { + 'algorithm': algorithm.algorithmString, + 'auth_data': authData, + 'version': version, + }, + ); + } + + /// Deletes a room key backup + /// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-version-version + Future deleteRoomKeysBackup(String version) async { + await request( + RequestType.DELETE, + '/client/unstable/room_keys/version/${Uri.encodeComponent(version)}', + ); + } + + /// Stores a single room key + /// https://matrix.org/docs/spec/client_server/unstable#put-matrix-client-r0-room-keys-keys-roomid-sessionid + Future storeRoomKeysSingleKey(String roomId, + String sessionId, String version, RoomKeysSingleKey session) async { + final ret = await request( + RequestType.PUT, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}/${Uri.encodeComponent(sessionId)}?version=${Uri.encodeComponent(version)}', + data: session.toJson(), + ); + return RoomKeysUpdateResponse.fromJson(ret); + } + + /// Gets a single room key + /// https://matrix.org/docs/spec/client_server/unstable#get-matrix-client-r0-room-keys-keys-roomid-sessionid + Future getRoomKeysSingleKey( + String roomId, String sessionId, String version) async { + final ret = await request( + RequestType.GET, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}/${Uri.encodeComponent(sessionId)}?version=${Uri.encodeComponent(version)}', + ); + return RoomKeysSingleKey.fromJson(ret); + } + + /// Deletes a single room key + /// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-keys-roomid-sessionid + Future deleteRoomKeysSingleKey( + String roomId, String sessionId, String version) async { + final ret = await request( + RequestType.DELETE, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}/${Uri.encodeComponent(sessionId)}?version=${Uri.encodeComponent(version)}', + ); + return RoomKeysUpdateResponse.fromJson(ret); + } + + /// Stores room keys for a room + /// https://matrix.org/docs/spec/client_server/unstable#put-matrix-client-r0-room-keys-keys-roomid + Future storeRoomKeysRoom( + String roomId, String version, RoomKeysRoom keys) async { + final ret = await request( + RequestType.PUT, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}?version=${Uri.encodeComponent(version)}', + data: keys.toJson(), + ); + return RoomKeysUpdateResponse.fromJson(ret); + } + + /// Gets room keys for a room + /// https://matrix.org/docs/spec/client_server/unstable#get-matrix-client-r0-room-keys-keys-roomid + Future getRoomKeysRoom(String roomId, String version) async { + final ret = await request( + RequestType.GET, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}?version=${Uri.encodeComponent(version)}', + ); + return RoomKeysRoom.fromJson(ret); + } + + /// Deletes room ekys for a room + /// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-keys-roomid + Future deleteRoomKeysRoom( + String roomId, String version) async { + final ret = await request( + RequestType.DELETE, + '/client/unstable/room_keys/keys/${Uri.encodeComponent(roomId)}?version=${Uri.encodeComponent(version)}', + ); + return RoomKeysUpdateResponse.fromJson(ret); + } + + /// Store multiple room keys + /// https://matrix.org/docs/spec/client_server/unstable#put-matrix-client-r0-room-keys-keys + Future storeRoomKeys( + String version, RoomKeys keys) async { + final ret = await request( + RequestType.PUT, + '/client/unstable/room_keys/keys?version=${Uri.encodeComponent(version)}', + data: keys.toJson(), + ); + return RoomKeysUpdateResponse.fromJson(ret); + } + + /// get all room keys + /// https://matrix.org/docs/spec/client_server/unstable#get-matrix-client-r0-room-keys-keys + Future getRoomKeys(String version) async { + final ret = await request( + RequestType.GET, + '/client/unstable/room_keys/keys?version=${Uri.encodeComponent(version)}', + ); + return RoomKeys.fromJson(ret); + } + + /// delete all room keys + /// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-keys + Future deleteRoomKeys(String version) async { + final ret = await request( + RequestType.DELETE, + '/client/unstable/room_keys/keys?version=${Uri.encodeComponent(version)}', + ); + return RoomKeysUpdateResponse.fromJson(ret); + } } diff --git a/lib/matrix_api/model/keys_query_response.dart b/lib/matrix_api/model/keys_query_response.dart index 8c6bb32..57bc16e 100644 --- a/lib/matrix_api/model/keys_query_response.dart +++ b/lib/matrix_api/model/keys_query_response.dart @@ -16,11 +16,14 @@ * along with this program. If not, see . */ -import 'matrix_device_keys.dart'; +import 'matrix_keys.dart'; class KeysQueryResponse { Map failures; Map> deviceKeys; + Map masterKeys; + Map selfSigningKeys; + Map userSigningKeys; KeysQueryResponse.fromJson(Map json) { failures = Map.from(json['failures']); @@ -37,6 +40,32 @@ class KeysQueryResponse { ), ) : null; + masterKeys = json['master_keys'] != null + ? (json['master_keys'] as Map).map( + (k, v) => MapEntry( + k, + MatrixCrossSigningKey.fromJson(v), + ), + ) + : null; + + selfSigningKeys = json['self_signing_keys'] != null + ? (json['self_signing_keys'] as Map).map( + (k, v) => MapEntry( + k, + MatrixCrossSigningKey.fromJson(v), + ), + ) + : null; + + userSigningKeys = json['user_signing_keys'] != null + ? (json['user_signing_keys'] as Map).map( + (k, v) => MapEntry( + k, + MatrixCrossSigningKey.fromJson(v), + ), + ) + : null; } Map toJson() { @@ -57,6 +86,30 @@ class KeysQueryResponse { ), ); } + if (masterKeys != null) { + data['master_keys'] = masterKeys.map( + (k, v) => MapEntry( + k, + v.toJson(), + ), + ); + } + if (selfSigningKeys != null) { + data['self_signing_keys'] = selfSigningKeys.map( + (k, v) => MapEntry( + k, + v.toJson(), + ), + ); + } + if (userSigningKeys != null) { + data['user_signing_keys'] = userSigningKeys.map( + (k, v) => MapEntry( + k, + v.toJson(), + ), + ); + } return data; } } diff --git a/lib/matrix_api/model/matrix_device_keys.dart b/lib/matrix_api/model/matrix_keys.dart similarity index 52% rename from lib/matrix_api/model/matrix_device_keys.dart rename to lib/matrix_api/model/matrix_keys.dart index 95a225b..c0f0038 100644 --- a/lib/matrix_api/model/matrix_device_keys.dart +++ b/lib/matrix_api/model/matrix_keys.dart @@ -16,38 +16,28 @@ * along with this program. If not, see . */ -class MatrixDeviceKeys { +class MatrixSignableKey { String userId; - String deviceId; - List algorithms; + String identifier; Map keys; Map> signatures; Map unsigned; - String get deviceDisplayName => - unsigned != null ? unsigned['device_display_name'] : null; + + MatrixSignableKey(this.userId, this.identifier, this.keys, this.signatures, + {this.unsigned}); // This object is used for signing so we need the raw json too Map _json; - MatrixDeviceKeys( - this.userId, - this.deviceId, - this.algorithms, - this.keys, - this.signatures, { - this.unsigned, - }); - - MatrixDeviceKeys.fromJson(Map json) { + MatrixSignableKey.fromJson(Map json) { _json = json; userId = json['user_id']; - deviceId = json['device_id']; - algorithms = json['algorithms'].cast(); keys = Map.from(json['keys']); - signatures = Map>.from( - (json['signatures'] as Map) - .map((k, v) => MapEntry(k, Map.from(v)))); - unsigned = json['unsigned'] != null + signatures = json['signatures'] is Map + ? Map>.from((json['signatures'] as Map) + .map((k, v) => MapEntry(k, Map.from(v)))) + : null; + unsigned = json['unsigned'] is Map ? Map.from(json['unsigned']) : null; } @@ -55,8 +45,6 @@ class MatrixDeviceKeys { Map toJson() { final data = _json ?? {}; data['user_id'] = userId; - data['device_id'] = deviceId; - data['algorithms'] = algorithms; data['keys'] = keys; if (signatures != null) { @@ -68,3 +56,60 @@ class MatrixDeviceKeys { return data; } } + +class MatrixCrossSigningKey extends MatrixSignableKey { + List usage; + String get publicKey => identifier; + + MatrixCrossSigningKey( + String userId, + this.usage, + Map keys, + Map> signatures, { + Map unsigned, + }) : super(userId, keys?.values?.first, keys, signatures, unsigned: unsigned); + + @override + MatrixCrossSigningKey.fromJson(Map json) + : super.fromJson(json) { + usage = List.from(json['usage']); + identifier = keys?.values?.first; + } + + @override + Map toJson() { + final data = super.toJson(); + data['usage'] = usage; + return data; + } +} + +class MatrixDeviceKeys extends MatrixSignableKey { + String get deviceId => identifier; + List algorithms; + String get deviceDisplayName => + unsigned != null ? unsigned['device_display_name'] : null; + + MatrixDeviceKeys( + String userId, + String deviceId, + this.algorithms, + Map keys, + Map> signatures, { + Map unsigned, + }) : super(userId, deviceId, keys, signatures, unsigned: unsigned); + + @override + MatrixDeviceKeys.fromJson(Map json) : super.fromJson(json) { + identifier = json['device_id']; + algorithms = json['algorithms'].cast(); + } + + @override + Map toJson() { + final data = super.toJson(); + data['device_id'] = deviceId; + data['algorithms'] = algorithms; + return data; + } +} diff --git a/lib/matrix_api/model/room_keys_info.dart b/lib/matrix_api/model/room_keys_info.dart new file mode 100644 index 0000000..f7a2cfe --- /dev/null +++ b/lib/matrix_api/model/room_keys_info.dart @@ -0,0 +1,67 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 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 . + */ + +enum RoomKeysAlgorithmType { v1Curve25519AesSha2 } + +extension RoomKeysAlgorithmTypeExtension on RoomKeysAlgorithmType { + String get algorithmString { + switch (this) { + case RoomKeysAlgorithmType.v1Curve25519AesSha2: + return 'm.megolm_backup.v1.curve25519-aes-sha2'; + default: + return null; + } + } + + static RoomKeysAlgorithmType fromAlgorithmString(String s) { + switch (s) { + case 'm.megolm_backup.v1.curve25519-aes-sha2': + return RoomKeysAlgorithmType.v1Curve25519AesSha2; + default: + return null; + } + } +} + +class RoomKeysVersionResponse { + RoomKeysAlgorithmType algorithm; + Map authData; + int count; + String etag; + String version; + + RoomKeysVersionResponse.fromJson(Map json) { + algorithm = + RoomKeysAlgorithmTypeExtension.fromAlgorithmString(json['algorithm']); + authData = json['auth_data']; + count = json['count']; + etag = + json['etag'].toString(); // synapse replies an int but docs say string? + version = json['version']; + } + + Map toJson() { + final data = {}; + data['algorithm'] = algorithm?.algorithmString; + data['auth_data'] = authData; + data['count'] = count; + data['etag'] = etag; + data['version'] = version; + return data; + } +} diff --git a/lib/matrix_api/model/room_keys_keys.dart b/lib/matrix_api/model/room_keys_keys.dart new file mode 100644 index 0000000..3b2c88e --- /dev/null +++ b/lib/matrix_api/model/room_keys_keys.dart @@ -0,0 +1,87 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 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 . + */ + +class RoomKeysSingleKey { + int firstMessageIndex; + int forwardedCount; + bool isVerified; + Map sessionData; + + RoomKeysSingleKey.fromJson(Map json) { + firstMessageIndex = json['first_message_index']; + forwardedCount = json['forwarded_count']; + isVerified = json['is_verified']; + sessionData = json['session_data']; + } + + Map toJson() { + final data = {}; + data['first_message_index'] = firstMessageIndex; + data['forwarded_count'] = forwardedCount; + data['is_verified'] = isVerified; + data['session_data'] = sessionData; + return data; + } +} + +class RoomKeysRoom { + Map sessions; + + RoomKeysRoom.fromJson(Map json) { + sessions = (json['sessions'] as Map) + .map((k, v) => MapEntry(k, RoomKeysSingleKey.fromJson(v))); + } + + Map toJson() { + final data = {}; + data['sessions'] = sessions.map((k, v) => MapEntry(k, v.toJson())); + return data; + } +} + +class RoomKeys { + Map rooms; + + RoomKeys.fromJson(Map json) { + rooms = (json['rooms'] as Map) + .map((k, v) => MapEntry(k, RoomKeysRoom.fromJson(v))); + } + + Map toJson() { + final data = {}; + data['rooms'] = rooms.map((k, v) => MapEntry(k, v.toJson())); + return data; + } +} + +class RoomKeysUpdateResponse { + String etag; + int count; + + RoomKeysUpdateResponse.fromJson(Map json) { + etag = json['etag']; // synapse replies an int but docs say string? + count = json['count']; + } + + Map toJson() { + final data = {}; + data['etag'] = etag; + data['count'] = count; + return data; + } +} diff --git a/lib/matrix_api/model/upload_key_signatures_response.dart b/lib/matrix_api/model/upload_key_signatures_response.dart new file mode 100644 index 0000000..325aa5e --- /dev/null +++ b/lib/matrix_api/model/upload_key_signatures_response.dart @@ -0,0 +1,55 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 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 'matrix_exception.dart'; + +class UploadKeySignaturesResponse { + Map> failures; + + UploadKeySignaturesResponse.fromJson(Map json) { + failures = json['failures'] != null + ? (json['failures'] as Map).map( + (k, v) => MapEntry( + k, + (v as Map).map((k, v) => MapEntry( + k, + MatrixException.fromJson(v), + )), + ), + ) + : null; + } + + Map toJson() { + final data = {}; + if (failures != null) { + data['failures'] = failures.map( + (k, v) => MapEntry( + k, + v.map( + (k, v) => MapEntry( + k, + v.raw, + ), + ), + ), + ); + } + return data; + } +} diff --git a/lib/src/client.dart b/lib/src/client.dart index bbf87e7..e61b13e 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -56,16 +56,23 @@ class Client { Encryption encryption; + Set verificationMethods; + /// Create a client /// clientName = unique identifier of this client /// debug: Print debug output? /// database: The database instance to use /// enableE2eeRecovery: Enable additional logic to try to recover from bad e2ee sessions + /// verificationMethods: A set of all the verification methods this client can handle. Includes: + /// KeyVerificationMethod.numbers: Compare numbers. Most basic, should be supported + /// KeyVerificationMethod.emoji: Compare emojis Client(this.clientName, {this.debug = false, this.database, this.enableE2eeRecovery = false, + this.verificationMethods, http.Client httpClient}) { + verificationMethods ??= {}; api = MatrixApi(debug: debug, httpClient: httpClient); onLoginStateChanged.stream.listen((loginState) { if (debug) { @@ -111,6 +118,12 @@ class Client { String get identityKey => encryption?.identityKey ?? ''; String get fingerprintKey => encryption?.fingerprintKey ?? ''; + /// Wheather this session is unknown to others + bool get isUnknownSession => + !userDeviceKeys.containsKey(userID) || + !userDeviceKeys[userID].deviceKeys.containsKey(deviceID) || + !userDeviceKeys[userID].deviceKeys[deviceID].signed; + /// Warning! This endpoint is for testing only! set rooms(List newList) { print('Warning! This endpoint is for testing only!'); @@ -627,7 +640,7 @@ class Client { encryption?.pickledOlmAccount, ); } - _userDeviceKeys = await database.getUserDeviceKeys(id); + _userDeviceKeys = await database.getUserDeviceKeys(this); _rooms = await database.getRoomList(this, onlyLeft: false); _sortRooms(); accountData = await database.getAccountData(id); @@ -953,6 +966,9 @@ class Client { await database.storeEventUpdate(id, update); } _updateRoomsByEventUpdate(update); + if (encryptionEnabled) { + await encryption.handleEventUpdate(update); + } onEvent.add(update); if (event['type'] == 'm.call.invite') { @@ -1124,7 +1140,7 @@ class Client { var outdatedLists = {}; for (var userId in trackedUserIds) { if (!userDeviceKeys.containsKey(userId)) { - _userDeviceKeys[userId] = DeviceKeysList(userId); + _userDeviceKeys[userId] = DeviceKeysList(userId, this); } var deviceKeysList = userDeviceKeys[userId]; if (deviceKeysList.outdated) { @@ -1140,7 +1156,7 @@ class Client { for (final rawDeviceKeyListEntry in response.deviceKeys.entries) { final userId = rawDeviceKeyListEntry.key; if (!userDeviceKeys.containsKey(userId)) { - _userDeviceKeys[userId] = DeviceKeysList(userId); + _userDeviceKeys[userId] = DeviceKeysList(userId, this); } final oldKeys = Map.from(_userDeviceKeys[userId].deviceKeys); @@ -1149,34 +1165,45 @@ class Client { final deviceId = rawDeviceKeyEntry.key; // Set the new device key for this device - - if (!oldKeys.containsKey(deviceId)) { - final entry = - DeviceKeys.fromMatrixDeviceKeys(rawDeviceKeyEntry.value); - if (entry.isValid) { + final entry = + DeviceKeys.fromMatrixDeviceKeys(rawDeviceKeyEntry.value, this); + if (entry.isValid) { + // is this a new key or the same one as an old one? + // better store an update - the signatures might have changed! + if (!oldKeys.containsKey(deviceId) || + oldKeys[deviceId].ed25519Key == entry.ed25519Key) { + if (oldKeys.containsKey(deviceId)) { + // be sure to save the verified status + entry.setDirectVerified(oldKeys[deviceId].directVerified); + entry.blocked = oldKeys[deviceId].blocked; + entry.validSignatures = oldKeys[deviceId].validSignatures; + } _userDeviceKeys[userId].deviceKeys[deviceId] = entry; if (deviceId == deviceID && - entry.ed25519Key == encryption?.fingerprintKey) { + entry.ed25519Key == fingerprintKey) { // Always trust the own device - entry.verified = true; + entry.setDirectVerified(true); } + } else { + // This shouldn't ever happen. The same device ID has gotten + // a new public key. So we ignore the update. TODO: ask krille + // if we should instead use the new key with unknown verified / blocked status + _userDeviceKeys[userId].deviceKeys[deviceId] = + oldKeys[deviceId]; } - if (database != null) { - dbActions.add(() => database.storeUserDeviceKey( - id, - userId, - deviceId, - json.encode(_userDeviceKeys[userId] - .deviceKeys[deviceId] - .toJson()), - _userDeviceKeys[userId].deviceKeys[deviceId].verified, - _userDeviceKeys[userId].deviceKeys[deviceId].blocked, - )); - } - } else { - _userDeviceKeys[userId].deviceKeys[deviceId] = oldKeys[deviceId]; + } + if (database != null) { + dbActions.add(() => database.storeUserDeviceKey( + id, + userId, + deviceId, + json.encode(entry.toJson()), + entry.directVerified, + entry.blocked, + )); } } + // delete old/unused entries if (database != null) { for (final oldDeviceKeyEntry in oldKeys.entries) { final deviceId = oldDeviceKeyEntry.key; @@ -1193,6 +1220,71 @@ class Client { .add(() => database.storeUserDeviceKeysInfo(id, userId, false)); } } + // next we parse and persist the cross signing keys + final crossSigningTypes = { + 'master': response.masterKeys, + 'self_signing': response.selfSigningKeys, + 'user_signing': response.userSigningKeys, + }; + for (final crossSigningKeysEntry in crossSigningTypes.entries) { + final keyType = crossSigningKeysEntry.key; + final keys = crossSigningKeysEntry.value; + if (keys == null) { + continue; + } + for (final crossSigningKeyListEntry in keys.entries) { + final userId = crossSigningKeyListEntry.key; + if (!userDeviceKeys.containsKey(userId)) { + _userDeviceKeys[userId] = DeviceKeysList(userId, this); + } + final oldKeys = Map.from( + _userDeviceKeys[userId].crossSigningKeys); + _userDeviceKeys[userId].crossSigningKeys = {}; + // add the types we aren't handling atm back + for (final oldEntry in oldKeys.entries) { + if (!oldEntry.value.usage.contains(keyType)) { + _userDeviceKeys[userId].crossSigningKeys[oldEntry.key] = + oldEntry.value; + } + } + final entry = CrossSigningKey.fromMatrixCrossSigningKey( + crossSigningKeyListEntry.value, this); + if (entry.isValid) { + final publicKey = entry.publicKey; + if (!oldKeys.containsKey(publicKey) || + oldKeys[publicKey].ed25519Key == entry.ed25519Key) { + if (oldKeys.containsKey(publicKey)) { + // be sure to save the verification status + entry.setDirectVerified(oldKeys[publicKey].directVerified); + entry.blocked = oldKeys[publicKey].blocked; + entry.validSignatures = oldKeys[publicKey].validSignatures; + } + _userDeviceKeys[userId].crossSigningKeys[publicKey] = entry; + } else { + // This shouldn't ever happen. The same device ID has gotten + // a new public key. So we ignore the update. TODO: ask krille + // if we should instead use the new key with unknown verified / blocked status + _userDeviceKeys[userId].crossSigningKeys[publicKey] = + oldKeys[publicKey]; + } + if (database != null) { + dbActions.add(() => database.storeUserCrossSigningKey( + id, + userId, + publicKey, + json.encode(entry.toJson()), + entry.directVerified, + entry.blocked, + )); + } + } + _userDeviceKeys[userId].outdated = false; + if (database != null) { + dbActions.add( + () => database.storeUserDeviceKeysInfo(id, userId, false)); + } + } + } } await database?.transaction(() async { for (final f in dbActions) { @@ -1212,12 +1304,16 @@ class Client { Map message, { bool encrypted = true, List toUsers, + bool onlyVerified = false, }) async { if (encrypted && !encryptionEnabled) return; - // Don't send this message to blocked devices. + // Don't send this message to blocked devices, and if specified onlyVerified + // then only send it to verified devices if (deviceKeys.isNotEmpty) { deviceKeys.removeWhere((DeviceKeys deviceKeys) => - deviceKeys.blocked || deviceKeys.deviceId == deviceID); + deviceKeys.blocked || + deviceKeys.deviceId == deviceID || + (onlyVerified && !deviceKeys.verified)); if (deviceKeys.isEmpty) return; } diff --git a/lib/src/database/database.dart b/lib/src/database/database.dart index 136ce64..92d8c6b 100644 --- a/lib/src/database/database.dart +++ b/lib/src/database/database.dart @@ -16,7 +16,7 @@ class Database extends _$Database { Database(QueryExecutor e) : super(e); @override - int get schemaVersion => 3; + int get schemaVersion => 4; int get maxFileSize => 1 * 1024 * 1024; @@ -44,6 +44,16 @@ class Database extends _$Database { if (from == 2) { await m.deleteTable('outbound_group_sessions'); await m.createTable(outboundGroupSessions); + from++; + } + if (from == 3) { + await m.createTable(userCrossSigningKeys); + await m.createIndex(userCrossSigningKeysIndex); + await m.createTable(ssssCache); + // mark all keys as outdated so that the cross signing keys will be fetched + await m.issueCustomQuery( + 'UPDATE user_device_keys SET outdated = true'); + from++; } }, beforeOpen: (_) async { @@ -64,16 +74,20 @@ class Database extends _$Database { } Future> getUserDeviceKeys( - int clientId) async { - final deviceKeys = await getAllUserDeviceKeys(clientId).get(); + sdk.Client client) async { + final deviceKeys = await getAllUserDeviceKeys(client.id).get(); if (deviceKeys.isEmpty) { return {}; } - final deviceKeysKeys = await getAllUserDeviceKeysKeys(clientId).get(); + final deviceKeysKeys = await getAllUserDeviceKeysKeys(client.id).get(); + final crossSigningKeys = await getAllUserCrossSigningKeys(client.id).get(); final res = {}; for (final entry in deviceKeys) { - res[entry.userId] = sdk.DeviceKeysList.fromDb(entry, - deviceKeysKeys.where((k) => k.userId == entry.userId).toList()); + res[entry.userId] = sdk.DeviceKeysList.fromDb( + entry, + deviceKeysKeys.where((k) => k.userId == entry.userId).toList(), + crossSigningKeys.where((k) => k.userId == entry.userId).toList(), + client); } return res; } @@ -140,6 +154,14 @@ class Database extends _$Database { return res.first; } + Future getSSSSCache(int clientId, String type) async { + final res = await dbGetSSSSCache(clientId, type).get(); + if (res.isEmpty) { + return null; + } + return res.first; + } + Future> getRoomList(sdk.Client client, {bool onlyLeft = false}) async { final res = await (select(rooms) @@ -428,11 +450,16 @@ class Database extends _$Database { await (delete(inboundGroupSessions) ..where((r) => r.clientId.equals(clientId))) .go(); + await (delete(ssssCache)..where((r) => r.clientId.equals(clientId))).go(); await (delete(olmSessions)..where((r) => r.clientId.equals(clientId))).go(); + await (delete(userCrossSigningKeys) + ..where((r) => r.clientId.equals(clientId))) + .go(); await (delete(userDeviceKeysKey)..where((r) => r.clientId.equals(clientId))) .go(); await (delete(userDeviceKeys)..where((r) => r.clientId.equals(clientId))) .go(); + await (delete(ssssCache)..where((r) => r.clientId.equals(clientId))).go(); await (delete(clients)..where((r) => r.clientId.equals(clientId))).go(); } diff --git a/lib/src/database/database.g.dart b/lib/src/database/database.g.dart index 488dee4..41485b6 100644 --- a/lib/src/database/database.g.dart +++ b/lib/src/database/database.g.dart @@ -1039,6 +1039,353 @@ class UserDeviceKeysKey extends Table bool get dontWriteConstraints => true; } +class DbUserCrossSigningKey extends DataClass + implements Insertable { + final int clientId; + final String userId; + final String publicKey; + final String content; + final bool verified; + final bool blocked; + DbUserCrossSigningKey( + {@required this.clientId, + @required this.userId, + @required this.publicKey, + @required this.content, + this.verified, + this.blocked}); + factory DbUserCrossSigningKey.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final intType = db.typeSystem.forDartType(); + final stringType = db.typeSystem.forDartType(); + final boolType = db.typeSystem.forDartType(); + return DbUserCrossSigningKey( + clientId: + intType.mapFromDatabaseResponse(data['${effectivePrefix}client_id']), + userId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + publicKey: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}public_key']), + content: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}content']), + verified: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}verified']), + blocked: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}blocked']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || clientId != null) { + map['client_id'] = Variable(clientId); + } + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || publicKey != null) { + map['public_key'] = Variable(publicKey); + } + if (!nullToAbsent || content != null) { + map['content'] = Variable(content); + } + if (!nullToAbsent || verified != null) { + map['verified'] = Variable(verified); + } + if (!nullToAbsent || blocked != null) { + map['blocked'] = Variable(blocked); + } + return map; + } + + factory DbUserCrossSigningKey.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return DbUserCrossSigningKey( + clientId: serializer.fromJson(json['client_id']), + userId: serializer.fromJson(json['user_id']), + publicKey: serializer.fromJson(json['public_key']), + content: serializer.fromJson(json['content']), + verified: serializer.fromJson(json['verified']), + blocked: serializer.fromJson(json['blocked']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'client_id': serializer.toJson(clientId), + 'user_id': serializer.toJson(userId), + 'public_key': serializer.toJson(publicKey), + 'content': serializer.toJson(content), + 'verified': serializer.toJson(verified), + 'blocked': serializer.toJson(blocked), + }; + } + + DbUserCrossSigningKey copyWith( + {int clientId, + String userId, + String publicKey, + String content, + bool verified, + bool blocked}) => + DbUserCrossSigningKey( + clientId: clientId ?? this.clientId, + userId: userId ?? this.userId, + publicKey: publicKey ?? this.publicKey, + content: content ?? this.content, + verified: verified ?? this.verified, + blocked: blocked ?? this.blocked, + ); + @override + String toString() { + return (StringBuffer('DbUserCrossSigningKey(') + ..write('clientId: $clientId, ') + ..write('userId: $userId, ') + ..write('publicKey: $publicKey, ') + ..write('content: $content, ') + ..write('verified: $verified, ') + ..write('blocked: $blocked') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + clientId.hashCode, + $mrjc( + userId.hashCode, + $mrjc( + publicKey.hashCode, + $mrjc(content.hashCode, + $mrjc(verified.hashCode, blocked.hashCode)))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is DbUserCrossSigningKey && + other.clientId == this.clientId && + other.userId == this.userId && + other.publicKey == this.publicKey && + other.content == this.content && + other.verified == this.verified && + other.blocked == this.blocked); +} + +class UserCrossSigningKeysCompanion + extends UpdateCompanion { + final Value clientId; + final Value userId; + final Value publicKey; + final Value content; + final Value verified; + final Value blocked; + const UserCrossSigningKeysCompanion({ + this.clientId = const Value.absent(), + this.userId = const Value.absent(), + this.publicKey = const Value.absent(), + this.content = const Value.absent(), + this.verified = const Value.absent(), + this.blocked = const Value.absent(), + }); + UserCrossSigningKeysCompanion.insert({ + @required int clientId, + @required String userId, + @required String publicKey, + @required String content, + this.verified = const Value.absent(), + this.blocked = const Value.absent(), + }) : clientId = Value(clientId), + userId = Value(userId), + publicKey = Value(publicKey), + content = Value(content); + static Insertable custom({ + Expression clientId, + Expression userId, + Expression publicKey, + Expression content, + Expression verified, + Expression blocked, + }) { + return RawValuesInsertable({ + if (clientId != null) 'client_id': clientId, + if (userId != null) 'user_id': userId, + if (publicKey != null) 'public_key': publicKey, + if (content != null) 'content': content, + if (verified != null) 'verified': verified, + if (blocked != null) 'blocked': blocked, + }); + } + + UserCrossSigningKeysCompanion copyWith( + {Value clientId, + Value userId, + Value publicKey, + Value content, + Value verified, + Value blocked}) { + return UserCrossSigningKeysCompanion( + clientId: clientId ?? this.clientId, + userId: userId ?? this.userId, + publicKey: publicKey ?? this.publicKey, + content: content ?? this.content, + verified: verified ?? this.verified, + blocked: blocked ?? this.blocked, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (clientId.present) { + map['client_id'] = Variable(clientId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (publicKey.present) { + map['public_key'] = Variable(publicKey.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (verified.present) { + map['verified'] = Variable(verified.value); + } + if (blocked.present) { + map['blocked'] = Variable(blocked.value); + } + return map; + } +} + +class UserCrossSigningKeys extends Table + with TableInfo { + final GeneratedDatabase _db; + final String _alias; + UserCrossSigningKeys(this._db, [this._alias]); + final VerificationMeta _clientIdMeta = const VerificationMeta('clientId'); + GeneratedIntColumn _clientId; + GeneratedIntColumn get clientId => _clientId ??= _constructClientId(); + GeneratedIntColumn _constructClientId() { + return GeneratedIntColumn('client_id', $tableName, false, + $customConstraints: 'NOT NULL REFERENCES clients(client_id)'); + } + + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + GeneratedTextColumn _userId; + GeneratedTextColumn get userId => _userId ??= _constructUserId(); + GeneratedTextColumn _constructUserId() { + return GeneratedTextColumn('user_id', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _publicKeyMeta = const VerificationMeta('publicKey'); + GeneratedTextColumn _publicKey; + GeneratedTextColumn get publicKey => _publicKey ??= _constructPublicKey(); + GeneratedTextColumn _constructPublicKey() { + return GeneratedTextColumn('public_key', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _contentMeta = const VerificationMeta('content'); + GeneratedTextColumn _content; + GeneratedTextColumn get content => _content ??= _constructContent(); + GeneratedTextColumn _constructContent() { + return GeneratedTextColumn('content', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _verifiedMeta = const VerificationMeta('verified'); + GeneratedBoolColumn _verified; + GeneratedBoolColumn get verified => _verified ??= _constructVerified(); + GeneratedBoolColumn _constructVerified() { + return GeneratedBoolColumn('verified', $tableName, true, + $customConstraints: 'DEFAULT false', + defaultValue: const CustomExpression('false')); + } + + final VerificationMeta _blockedMeta = const VerificationMeta('blocked'); + GeneratedBoolColumn _blocked; + GeneratedBoolColumn get blocked => _blocked ??= _constructBlocked(); + GeneratedBoolColumn _constructBlocked() { + return GeneratedBoolColumn('blocked', $tableName, true, + $customConstraints: 'DEFAULT false', + defaultValue: const CustomExpression('false')); + } + + @override + List get $columns => + [clientId, userId, publicKey, content, verified, blocked]; + @override + UserCrossSigningKeys get asDslTable => this; + @override + String get $tableName => _alias ?? 'user_cross_signing_keys'; + @override + final String actualTableName = 'user_cross_signing_keys'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('client_id')) { + context.handle(_clientIdMeta, + clientId.isAcceptableOrUnknown(data['client_id'], _clientIdMeta)); + } else if (isInserting) { + context.missing(_clientIdMeta); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('public_key')) { + context.handle(_publicKeyMeta, + publicKey.isAcceptableOrUnknown(data['public_key'], _publicKeyMeta)); + } else if (isInserting) { + context.missing(_publicKeyMeta); + } + if (data.containsKey('content')) { + context.handle(_contentMeta, + content.isAcceptableOrUnknown(data['content'], _contentMeta)); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('verified')) { + context.handle(_verifiedMeta, + verified.isAcceptableOrUnknown(data['verified'], _verifiedMeta)); + } + if (data.containsKey('blocked')) { + context.handle(_blockedMeta, + blocked.isAcceptableOrUnknown(data['blocked'], _blockedMeta)); + } + return context; + } + + @override + Set get $primaryKey => {}; + @override + DbUserCrossSigningKey map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return DbUserCrossSigningKey.fromData(data, _db, prefix: effectivePrefix); + } + + @override + UserCrossSigningKeys createAlias(String alias) { + return UserCrossSigningKeys(_db, alias); + } + + @override + List get customConstraints => + const ['UNIQUE(client_id, user_id, public_key)']; + @override + bool get dontWriteConstraints => true; +} + class DbOlmSessions extends DataClass implements Insertable { final int clientId; final String identityKey; @@ -4454,6 +4801,311 @@ class Presences extends Table with TableInfo { bool get dontWriteConstraints => true; } +class DbSSSSCache extends DataClass implements Insertable { + final int clientId; + final String type; + final String keyId; + final String ciphertext; + final String content; + DbSSSSCache( + {@required this.clientId, + @required this.type, + @required this.keyId, + @required this.ciphertext, + @required this.content}); + factory DbSSSSCache.fromData(Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final intType = db.typeSystem.forDartType(); + final stringType = db.typeSystem.forDartType(); + return DbSSSSCache( + clientId: + intType.mapFromDatabaseResponse(data['${effectivePrefix}client_id']), + type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), + keyId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}key_id']), + ciphertext: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}ciphertext']), + content: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}content']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || clientId != null) { + map['client_id'] = Variable(clientId); + } + if (!nullToAbsent || type != null) { + map['type'] = Variable(type); + } + if (!nullToAbsent || keyId != null) { + map['key_id'] = Variable(keyId); + } + if (!nullToAbsent || ciphertext != null) { + map['ciphertext'] = Variable(ciphertext); + } + if (!nullToAbsent || content != null) { + map['content'] = Variable(content); + } + return map; + } + + factory DbSSSSCache.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return DbSSSSCache( + clientId: serializer.fromJson(json['client_id']), + type: serializer.fromJson(json['type']), + keyId: serializer.fromJson(json['key_id']), + ciphertext: serializer.fromJson(json['ciphertext']), + content: serializer.fromJson(json['content']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'client_id': serializer.toJson(clientId), + 'type': serializer.toJson(type), + 'key_id': serializer.toJson(keyId), + 'ciphertext': serializer.toJson(ciphertext), + 'content': serializer.toJson(content), + }; + } + + DbSSSSCache copyWith( + {int clientId, + String type, + String keyId, + String ciphertext, + String content}) => + DbSSSSCache( + clientId: clientId ?? this.clientId, + type: type ?? this.type, + keyId: keyId ?? this.keyId, + ciphertext: ciphertext ?? this.ciphertext, + content: content ?? this.content, + ); + @override + String toString() { + return (StringBuffer('DbSSSSCache(') + ..write('clientId: $clientId, ') + ..write('type: $type, ') + ..write('keyId: $keyId, ') + ..write('ciphertext: $ciphertext, ') + ..write('content: $content') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + clientId.hashCode, + $mrjc( + type.hashCode, + $mrjc( + keyId.hashCode, $mrjc(ciphertext.hashCode, content.hashCode))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is DbSSSSCache && + other.clientId == this.clientId && + other.type == this.type && + other.keyId == this.keyId && + other.ciphertext == this.ciphertext && + other.content == this.content); +} + +class SsssCacheCompanion extends UpdateCompanion { + final Value clientId; + final Value type; + final Value keyId; + final Value ciphertext; + final Value content; + const SsssCacheCompanion({ + this.clientId = const Value.absent(), + this.type = const Value.absent(), + this.keyId = const Value.absent(), + this.ciphertext = const Value.absent(), + this.content = const Value.absent(), + }); + SsssCacheCompanion.insert({ + @required int clientId, + @required String type, + @required String keyId, + @required String ciphertext, + @required String content, + }) : clientId = Value(clientId), + type = Value(type), + keyId = Value(keyId), + ciphertext = Value(ciphertext), + content = Value(content); + static Insertable custom({ + Expression clientId, + Expression type, + Expression keyId, + Expression ciphertext, + Expression content, + }) { + return RawValuesInsertable({ + if (clientId != null) 'client_id': clientId, + if (type != null) 'type': type, + if (keyId != null) 'key_id': keyId, + if (ciphertext != null) 'ciphertext': ciphertext, + if (content != null) 'content': content, + }); + } + + SsssCacheCompanion copyWith( + {Value clientId, + Value type, + Value keyId, + Value ciphertext, + Value content}) { + return SsssCacheCompanion( + clientId: clientId ?? this.clientId, + type: type ?? this.type, + keyId: keyId ?? this.keyId, + ciphertext: ciphertext ?? this.ciphertext, + content: content ?? this.content, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (clientId.present) { + map['client_id'] = Variable(clientId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (keyId.present) { + map['key_id'] = Variable(keyId.value); + } + if (ciphertext.present) { + map['ciphertext'] = Variable(ciphertext.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + return map; + } +} + +class SsssCache extends Table with TableInfo { + final GeneratedDatabase _db; + final String _alias; + SsssCache(this._db, [this._alias]); + final VerificationMeta _clientIdMeta = const VerificationMeta('clientId'); + GeneratedIntColumn _clientId; + GeneratedIntColumn get clientId => _clientId ??= _constructClientId(); + GeneratedIntColumn _constructClientId() { + return GeneratedIntColumn('client_id', $tableName, false, + $customConstraints: 'NOT NULL REFERENCES clients(client_id)'); + } + + final VerificationMeta _typeMeta = const VerificationMeta('type'); + GeneratedTextColumn _type; + GeneratedTextColumn get type => _type ??= _constructType(); + GeneratedTextColumn _constructType() { + return GeneratedTextColumn('type', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _keyIdMeta = const VerificationMeta('keyId'); + GeneratedTextColumn _keyId; + GeneratedTextColumn get keyId => _keyId ??= _constructKeyId(); + GeneratedTextColumn _constructKeyId() { + return GeneratedTextColumn('key_id', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _ciphertextMeta = const VerificationMeta('ciphertext'); + GeneratedTextColumn _ciphertext; + GeneratedTextColumn get ciphertext => _ciphertext ??= _constructCiphertext(); + GeneratedTextColumn _constructCiphertext() { + return GeneratedTextColumn('ciphertext', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + final VerificationMeta _contentMeta = const VerificationMeta('content'); + GeneratedTextColumn _content; + GeneratedTextColumn get content => _content ??= _constructContent(); + GeneratedTextColumn _constructContent() { + return GeneratedTextColumn('content', $tableName, false, + $customConstraints: 'NOT NULL'); + } + + @override + List get $columns => + [clientId, type, keyId, ciphertext, content]; + @override + SsssCache get asDslTable => this; + @override + String get $tableName => _alias ?? 'ssss_cache'; + @override + final String actualTableName = 'ssss_cache'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('client_id')) { + context.handle(_clientIdMeta, + clientId.isAcceptableOrUnknown(data['client_id'], _clientIdMeta)); + } else if (isInserting) { + context.missing(_clientIdMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('key_id')) { + context.handle( + _keyIdMeta, keyId.isAcceptableOrUnknown(data['key_id'], _keyIdMeta)); + } else if (isInserting) { + context.missing(_keyIdMeta); + } + if (data.containsKey('ciphertext')) { + context.handle( + _ciphertextMeta, + ciphertext.isAcceptableOrUnknown( + data['ciphertext'], _ciphertextMeta)); + } else if (isInserting) { + context.missing(_ciphertextMeta); + } + if (data.containsKey('content')) { + context.handle(_contentMeta, + content.isAcceptableOrUnknown(data['content'], _contentMeta)); + } else if (isInserting) { + context.missing(_contentMeta); + } + return context; + } + + @override + Set get $primaryKey => {}; + @override + DbSSSSCache map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return DbSSSSCache.fromData(data, _db, prefix: effectivePrefix); + } + + @override + SsssCache createAlias(String alias) { + return SsssCache(_db, alias); + } + + @override + List get customConstraints => const ['UNIQUE(client_id, type)']; + @override + bool get dontWriteConstraints => true; +} + class DbFile extends DataClass implements Insertable { final String mxcUri; final Uint8List bytes; @@ -4680,6 +5332,13 @@ abstract class _$Database extends GeneratedDatabase { Index get userDeviceKeysKeyIndex => _userDeviceKeysKeyIndex ??= Index( 'user_device_keys_key_index', 'CREATE INDEX user_device_keys_key_index ON user_device_keys_key(client_id);'); + UserCrossSigningKeys _userCrossSigningKeys; + UserCrossSigningKeys get userCrossSigningKeys => + _userCrossSigningKeys ??= UserCrossSigningKeys(this); + Index _userCrossSigningKeysIndex; + Index get userCrossSigningKeysIndex => _userCrossSigningKeysIndex ??= Index( + 'user_cross_signing_keys_index', + 'CREATE INDEX user_cross_signing_keys_index ON user_cross_signing_keys(client_id);'); OlmSessions _olmSessions; OlmSessions get olmSessions => _olmSessions ??= OlmSessions(this); Index _olmSessionsIndex; @@ -4733,6 +5392,8 @@ abstract class _$Database extends GeneratedDatabase { Index _presencesIndex; Index get presencesIndex => _presencesIndex ??= Index('presences_index', 'CREATE INDEX presences_index ON presences(client_id);'); + SsssCache _ssssCache; + SsssCache get ssssCache => _ssssCache ??= SsssCache(this); Files _files; Files get files => _files ??= Files(this); DbClient _rowToDbClient(QueryRow row) { @@ -4835,6 +5496,24 @@ abstract class _$Database extends GeneratedDatabase { readsFrom: {userDeviceKeysKey}).map(_rowToDbUserDeviceKeysKey); } + DbUserCrossSigningKey _rowToDbUserCrossSigningKey(QueryRow row) { + return DbUserCrossSigningKey( + clientId: row.readInt('client_id'), + userId: row.readString('user_id'), + publicKey: row.readString('public_key'), + content: row.readString('content'), + verified: row.readBool('verified'), + blocked: row.readBool('blocked'), + ); + } + + Selectable getAllUserCrossSigningKeys(int client_id) { + return customSelect( + 'SELECT * FROM user_cross_signing_keys WHERE client_id = :client_id', + variables: [Variable.withInt(client_id)], + readsFrom: {userCrossSigningKeys}).map(_rowToDbUserCrossSigningKey); + } + DbOlmSessions _rowToDbOlmSessions(QueryRow row) { return DbOlmSessions( clientId: row.readInt('client_id'), @@ -5079,6 +5758,107 @@ abstract class _$Database extends GeneratedDatabase { ); } + Future setVerifiedUserCrossSigningKey( + bool verified, int client_id, String user_id, String public_key) { + return customUpdate( + 'UPDATE user_cross_signing_keys SET verified = :verified WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key', + variables: [ + Variable.withBool(verified), + Variable.withInt(client_id), + Variable.withString(user_id), + Variable.withString(public_key) + ], + updates: {userCrossSigningKeys}, + updateKind: UpdateKind.update, + ); + } + + Future setBlockedUserCrossSigningKey( + bool blocked, int client_id, String user_id, String public_key) { + return customUpdate( + 'UPDATE user_cross_signing_keys SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key', + variables: [ + Variable.withBool(blocked), + Variable.withInt(client_id), + Variable.withString(user_id), + Variable.withString(public_key) + ], + updates: {userCrossSigningKeys}, + updateKind: UpdateKind.update, + ); + } + + Future storeUserCrossSigningKey(int client_id, String user_id, + String public_key, String content, bool verified, bool blocked) { + return customInsert( + 'INSERT OR REPLACE INTO user_cross_signing_keys (client_id, user_id, public_key, content, verified, blocked) VALUES (:client_id, :user_id, :public_key, :content, :verified, :blocked)', + variables: [ + Variable.withInt(client_id), + Variable.withString(user_id), + Variable.withString(public_key), + Variable.withString(content), + Variable.withBool(verified), + Variable.withBool(blocked) + ], + updates: {userCrossSigningKeys}, + ); + } + + Future removeUserCrossSigningKey( + int client_id, String user_id, String public_key) { + return customUpdate( + 'DELETE FROM user_cross_signing_keys WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key', + variables: [ + Variable.withInt(client_id), + Variable.withString(user_id), + Variable.withString(public_key) + ], + updates: {userCrossSigningKeys}, + updateKind: UpdateKind.delete, + ); + } + + Future storeSSSSCache(int client_id, String type, String key_id, + String ciphertext, String content) { + return customInsert( + 'INSERT OR REPLACE INTO ssss_cache (client_id, type, key_id, ciphertext, content) VALUES (:client_id, :type, :key_id, :ciphertext, :content)', + variables: [ + Variable.withInt(client_id), + Variable.withString(type), + Variable.withString(key_id), + Variable.withString(ciphertext), + Variable.withString(content) + ], + updates: {ssssCache}, + ); + } + + DbSSSSCache _rowToDbSSSSCache(QueryRow row) { + return DbSSSSCache( + clientId: row.readInt('client_id'), + type: row.readString('type'), + keyId: row.readString('key_id'), + ciphertext: row.readString('ciphertext'), + content: row.readString('content'), + ); + } + + Selectable dbGetSSSSCache(int client_id, String type) { + return customSelect( + 'SELECT * FROM ssss_cache WHERE client_id = :client_id AND type = :type', + variables: [Variable.withInt(client_id), Variable.withString(type)], + readsFrom: {ssssCache}).map(_rowToDbSSSSCache); + } + + Future clearSSSSCache(int client_id) { + return customUpdate( + 'DELETE FROM ssss_cache WHERE client_id = :client_id', + variables: [Variable.withInt(client_id)], + updates: {ssssCache}, + updateKind: UpdateKind.delete, + ); + } + Future insertClient( String name, String homeserver_url, @@ -5508,6 +6288,8 @@ abstract class _$Database extends GeneratedDatabase { userDeviceKeysIndex, userDeviceKeysKey, userDeviceKeysKeyIndex, + userCrossSigningKeys, + userCrossSigningKeysIndex, olmSessions, olmSessionsIndex, outboundGroupSessions, @@ -5526,6 +6308,7 @@ abstract class _$Database extends GeneratedDatabase { roomAccountDataIndex, presences, presencesIndex, + ssssCache, files ]; } diff --git a/lib/src/database/database.moor b/lib/src/database/database.moor index 20db688..49a108d 100644 --- a/lib/src/database/database.moor +++ b/lib/src/database/database.moor @@ -32,6 +32,17 @@ CREATE TABLE user_device_keys_key ( ) as DbUserDeviceKeysKey; CREATE INDEX user_device_keys_key_index ON user_device_keys_key(client_id); +CREATE TABLE user_cross_signing_keys ( + client_id INTEGER NOT NULL REFERENCES clients(client_id), + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + content TEXT NOT NULL, + verified BOOLEAN DEFAULT false, + blocked BOOLEAN DEFAULT false, + UNIQUE(client_id, user_id, public_key) +) as DbUserCrossSigningKey; +CREATE INDEX user_cross_signing_keys_index ON user_cross_signing_keys(client_id); + CREATE TABLE olm_sessions ( client_id INTEGER NOT NULL REFERENCES clients(client_id), identity_key TEXT NOT NULL, @@ -63,6 +74,15 @@ CREATE TABLE inbound_group_sessions ( ) AS DbInboundGroupSession; CREATE INDEX inbound_group_sessions_index ON inbound_group_sessions(client_id); +CREATE TABLE ssss_cache ( + client_id INTEGER NOT NULL REFERENCES clients(client_id), + type TEXT NOT NULL, + key_id TEXT NOT NULL, + ciphertext TEXT NOT NULL, + content TEXT NOT NULL, + UNIQUE(client_id, type) +) AS DbSSSSCache; + CREATE TABLE rooms ( client_id INTEGER NOT NULL REFERENCES clients(client_id), room_id TEXT NOT NULL, @@ -154,6 +174,7 @@ updateClientKeys: UPDATE clients SET olm_account = :olm_account WHERE client_id storePrevBatch: UPDATE clients SET prev_batch = :prev_batch WHERE client_id = :client_id; getAllUserDeviceKeys: SELECT * FROM user_device_keys WHERE client_id = :client_id; getAllUserDeviceKeysKeys: SELECT * FROM user_device_keys_key WHERE client_id = :client_id; +getAllUserCrossSigningKeys: SELECT * FROM user_cross_signing_keys WHERE client_id = :client_id; getAllOlmSessions: SELECT * FROM olm_sessions WHERE client_id = :client_id; dbGetOlmSessions: SELECT * FROM olm_sessions WHERE client_id = :client_id AND identity_key = :identity_key; storeOlmSession: INSERT OR REPLACE INTO olm_sessions (client_id, identity_key, session_id, pickle) VALUES (:client_id, :identitiy_key, :session_id, :pickle); @@ -171,6 +192,13 @@ setVerifiedUserDeviceKey: UPDATE user_device_keys_key SET verified = :verified W setBlockedUserDeviceKey: UPDATE user_device_keys_key SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id; storeUserDeviceKey: INSERT OR REPLACE INTO user_device_keys_key (client_id, user_id, device_id, content, verified, blocked) VALUES (:client_id, :user_id, :device_id, :content, :verified, :blocked); removeUserDeviceKey: DELETE FROM user_device_keys_key WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id; +setVerifiedUserCrossSigningKey: UPDATE user_cross_signing_keys SET verified = :verified WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key; +setBlockedUserCrossSigningKey: UPDATE user_cross_signing_keys SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key; +storeUserCrossSigningKey: INSERT OR REPLACE INTO user_cross_signing_keys (client_id, user_id, public_key, content, verified, blocked) VALUES (:client_id, :user_id, :public_key, :content, :verified, :blocked); +removeUserCrossSigningKey: DELETE FROM user_cross_signing_keys WHERE client_id = :client_id AND user_id = :user_id AND public_key = :public_key; +storeSSSSCache: INSERT OR REPLACE INTO ssss_cache (client_id, type, key_id, ciphertext, content) VALUES (:client_id, :type, :key_id, :ciphertext, :content); +dbGetSSSSCache: SELECT * FROM ssss_cache WHERE client_id = :client_id AND type = :type; +clearSSSSCache: DELETE FROM ssss_cache WHERE client_id = :client_id; insertClient: INSERT INTO clients (name, homeserver_url, token, user_id, device_id, device_name, prev_batch, olm_account) VALUES (:name, :homeserver_url, :token, :user_id, :device_id, :device_name, :prev_batch, :olm_account); ensureRoomExists: INSERT OR IGNORE INTO rooms (client_id, room_id, membership) VALUES (:client_id, :room_id, :membership); setRoomPrevBatch: UPDATE rooms SET prev_batch = :prev_batch WHERE client_id = :client_id AND room_id = :room_id; diff --git a/lib/src/room.dart b/lib/src/room.dart index 6c74aab..a6fa024 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -465,7 +465,9 @@ class Room { final event = room.getState('im.ponies.room_emotes', stateKey); if (event != null && stateKeyEntry.value is Map) { addEmotePack( - room.canonicalAlias.isEmpty ? room.id : canonicalAlias, + (room.canonicalAlias?.isEmpty ?? true) + ? room.id + : canonicalAlias, event.content, stateKeyEntry.value['name']); } diff --git a/lib/src/utils/device_keys_list.dart b/lib/src/utils/device_keys_list.dart index 5fc0c85..c8430b7 100644 --- a/lib/src/utils/device_keys_list.dart +++ b/lib/src/utils/device_keys_list.dart @@ -1,158 +1,371 @@ import 'dart:convert'; +import 'package:canonical_json/canonical_json.dart'; +import 'package:olm/olm.dart' as olm; import 'package:famedlysdk/matrix_api.dart'; import 'package:famedlysdk/encryption.dart'; import '../client.dart'; -import '../database/database.dart' show DbUserDeviceKey, DbUserDeviceKeysKey; +import '../user.dart'; +import '../room.dart'; +import '../database/database.dart' + show DbUserDeviceKey, DbUserDeviceKeysKey, DbUserCrossSigningKey; import '../event.dart'; +enum UserVerifiedStatus { verified, unknown, unknownDevice } + class DeviceKeysList { + Client client; String userId; bool outdated = true; Map deviceKeys = {}; + Map crossSigningKeys = {}; + + SignableKey getKey(String id) { + if (deviceKeys.containsKey(id)) { + return deviceKeys[id]; + } + if (crossSigningKeys.containsKey(id)) { + return crossSigningKeys[id]; + } + return null; + } + + CrossSigningKey getCrossSigningKey(String type) => crossSigningKeys.values + .firstWhere((k) => k.usage.contains(type), orElse: () => null); + + 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; + } + return UserVerifiedStatus.unknown; + } + + Future startVerification() async { + final roomId = + await User(userId, room: Room(client: client)).startDirectChat(); + if (roomId == null) { + throw 'Unable to start new room'; + } + final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client); + final request = KeyVerification( + encryption: client.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; + } DeviceKeysList.fromDb( - DbUserDeviceKey dbEntry, List childEntries) { + DbUserDeviceKey dbEntry, + List childEntries, + List crossSigningEntries, + Client cl) { + client = cl; userId = dbEntry.userId; outdated = dbEntry.outdated; deviceKeys = {}; for (final childEntry in childEntries) { - final entry = DeviceKeys.fromDb(childEntry); + final entry = DeviceKeys.fromDb(childEntry, client); if (entry.isValid) { deviceKeys[childEntry.deviceId] = entry; } else { outdated = true; } } - } - - DeviceKeysList.fromJson(Map json) { - userId = json['user_id']; - outdated = json['outdated']; - deviceKeys = {}; - for (final rawDeviceKeyEntry in json['device_keys'].entries) { - deviceKeys[rawDeviceKeyEntry.key] = - DeviceKeys.fromJson(rawDeviceKeyEntry.value); + for (final crossSigningEntry in crossSigningEntries) { + final entry = CrossSigningKey.fromDb(crossSigningEntry, client); + if (entry.isValid) { + crossSigningKeys[crossSigningEntry.publicKey] = entry; + } else { + outdated = true; + } } } + DeviceKeysList(this.userId, this.client); +} + +abstract class SignableKey extends MatrixSignableKey { + Client client; + Map validSignatures; + bool _verified; + bool blocked; + + String get ed25519Key => keys['ed25519:$identifier']; + bool get verified => (directVerified || crossVerified) && !blocked; + + void setDirectVerified(bool v) { + _verified = v; + } + + bool get directVerified => _verified; + 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; + } + + MatrixSignableKey cloneForSigning() { + final newKey = + MatrixSignableKey.fromJson(Map.from(toJson())); + newKey.identifier = identifier; + newKey.signatures ??= >{}; + newKey.signatures.clear(); + return newKey; + } + + String get signingContent { + final data = Map.from(super.toJson()); + // 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) { + final olmutil = olm.Utility(); + 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}) { + if (!client.encryptionEnabled) { + return false; + } + visited ??= {}; + final setKey = '${userId};${identifier}'; + if (visited.contains(setKey)) { + return false; // prevent recursion + } + visited.add(setKey); + for (final signatureEntries in signatures.entries) { + final otherUserId = signatureEntries.key; + if (!(signatureEntries.value is Map) || + !client.userDeviceKeys.containsKey(otherUserId)) { + continue; + } + for (final signatureEntry in signatureEntries.value.entries) { + final fullKeyId = signatureEntry.key; + final signature = signatureEntry.value; + if (!(fullKeyId is String) || !(signature is String)) { + continue; + } + final keyId = fullKeyId.substring('ed25519:'.length); + SignableKey key; + if (client.userDeviceKeys[otherUserId].deviceKeys.containsKey(keyId)) { + key = client.userDeviceKeys[otherUserId].deviceKeys[keyId]; + } else if (client.userDeviceKeys[otherUserId].crossSigningKeys + .containsKey(keyId)) { + key = client.userDeviceKeys[otherUserId].crossSigningKeys[keyId]; + } else { + continue; + } + if (key.blocked) { + continue; // we can't be bothered about this keys signatures + } + var haveValidSignature = false; + var gotSignatureFromCache = false; + if (validSignatures != null && + validSignatures.containsKey(otherUserId) && + validSignatures[otherUserId].containsKey(fullKeyId)) { + if (validSignatures[otherUserId][fullKeyId] == true) { + haveValidSignature = true; + gotSignatureFromCache = true; + } else if (validSignatures[otherUserId][fullKeyId] == false) { + haveValidSignature = false; + gotSignatureFromCache = true; + } + } + if (!gotSignatureFromCache) { + // validate the signature manually + haveValidSignature = _verifySignature(key.ed25519Key, signature); + 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); + if (haveChain) { + return true; + } + } + } + return false; + } + + void setVerified(bool newVerified, [bool sign = true]) { + _verified = newVerified; + if (newVerified && + sign && + client.encryptionEnabled && + client.encryption.crossSigning.signable([this])) { + // sign the key! + client.encryption.crossSigning.sign([this]); + } + } + + Future setBlocked(bool newBlocked); + + @override Map toJson() { - var map = {}; - final data = map; - data['user_id'] = userId; - data['outdated'] = outdated ?? true; - - var rawDeviceKeys = {}; - for (final deviceKeyEntry in deviceKeys.entries) { - rawDeviceKeys[deviceKeyEntry.key] = deviceKeyEntry.value.toJson(); - } - data['device_keys'] = rawDeviceKeys; + final data = Map.from(super.toJson()); + // 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()); - - DeviceKeysList(this.userId); } -class DeviceKeys extends MatrixDeviceKeys { - bool verified; - bool blocked; +class CrossSigningKey extends SignableKey { + String get publicKey => identifier; + List usage; + + bool get isValid => + userId != null && publicKey != null && keys != null && ed25519Key != null; + + @override + Future setVerified(bool newVerified, [bool sign = true]) { + super.setVerified(newVerified, sign); + return client.database?.setVerifiedUserCrossSigningKey( + newVerified, client.id, userId, publicKey); + } + + @override + Future setBlocked(bool newBlocked) { + blocked = newBlocked; + return client.database?.setBlockedUserCrossSigningKey( + newBlocked, client.id, userId, publicKey); + } + + CrossSigningKey.fromMatrixCrossSigningKey(MatrixCrossSigningKey k, Client cl) + : super.fromJson(Map.from(k.toJson()), cl) { + final json = toJson(); + identifier = k.publicKey; + usage = json['usage'].cast(); + } + + CrossSigningKey.fromDb(DbUserCrossSigningKey dbEntry, Client cl) + : super.fromJson(Event.getMapFromPayload(dbEntry.content), cl) { + final json = toJson(); + identifier = dbEntry.publicKey; + usage = json['usage'].cast(); + _verified = dbEntry.verified; + blocked = dbEntry.blocked; + } + + CrossSigningKey.fromJson(Map json, Client cl) + : super.fromJson(Map.from(json), cl) { + final json = toJson(); + usage = json['usage'].cast(); + if (keys != null && keys.isNotEmpty) { + identifier = keys.values.first; + } + } +} + +class DeviceKeys extends SignableKey { + String get deviceId => identifier; + List algorithms; String get curve25519Key => keys['curve25519:$deviceId']; - String get ed25519Key => keys['ed25519:$deviceId']; + String get deviceDisplayName => + unsigned != null ? unsigned['device_display_name'] : null; bool get isValid => userId != null && deviceId != null && + keys != null && curve25519Key != null && ed25519Key != null; - Future setVerified(bool newVerified, Client client) { - verified = newVerified; - return client.database + @override + Future setVerified(bool newVerified, [bool sign = true]) { + super.setVerified(newVerified, sign); + return client?.database ?.setVerifiedUserDeviceKey(newVerified, client.id, userId, deviceId); } - Future setBlocked(bool newBlocked, Client client) { + @override + Future setBlocked(bool newBlocked) { blocked = newBlocked; - return client.database + return client?.database ?.setBlockedUserDeviceKey(newBlocked, client.id, userId, deviceId); } - DeviceKeys({ - String userId, - String deviceId, - List algorithms, - Map keys, - Map> signatures, - Map unsigned, - this.verified, - this.blocked, - }) : super(userId, deviceId, algorithms, keys, signatures, - unsigned: unsigned); - - factory DeviceKeys.fromMatrixDeviceKeys(MatrixDeviceKeys matrixDeviceKeys) => - DeviceKeys( - userId: matrixDeviceKeys.userId, - deviceId: matrixDeviceKeys.deviceId, - algorithms: matrixDeviceKeys.algorithms, - keys: matrixDeviceKeys.keys, - signatures: matrixDeviceKeys.signatures, - unsigned: matrixDeviceKeys.unsigned, - verified: false, - blocked: false, - ); - - static DeviceKeys fromDb(DbUserDeviceKeysKey dbEntry) { - var deviceKeys = DeviceKeys(); - final content = Event.getMapFromPayload(dbEntry.content); - deviceKeys.userId = dbEntry.userId; - deviceKeys.deviceId = dbEntry.deviceId; - deviceKeys.algorithms = content['algorithms'].cast(); - deviceKeys.keys = content['keys'] != null - ? Map.from(content['keys']) - : null; - deviceKeys.signatures = content['signatures'] != null - ? Map>.from((content['signatures'] as Map) - .map((k, v) => MapEntry(k, Map.from(v)))) - : null; - deviceKeys.unsigned = content['unsigned'] != null - ? Map.from(content['unsigned']) - : null; - deviceKeys.verified = dbEntry.verified; - deviceKeys.blocked = dbEntry.blocked; - return deviceKeys; + DeviceKeys.fromMatrixDeviceKeys(MatrixDeviceKeys k, Client cl) + : super.fromJson(Map.from(k.toJson()), cl) { + final json = toJson(); + identifier = k.deviceId; + algorithms = json['algorithms'].cast(); } - static DeviceKeys fromJson(Map json) { - var matrixDeviceKeys = MatrixDeviceKeys.fromJson(json); - var deviceKeys = DeviceKeys( - userId: matrixDeviceKeys.userId, - deviceId: matrixDeviceKeys.deviceId, - algorithms: matrixDeviceKeys.algorithms, - keys: matrixDeviceKeys.keys, - signatures: matrixDeviceKeys.signatures, - unsigned: matrixDeviceKeys.unsigned, - ); - deviceKeys.verified = json['verified'] ?? false; - deviceKeys.blocked = json['blocked'] ?? false; - return deviceKeys; + DeviceKeys.fromDb(DbUserDeviceKeysKey dbEntry, Client cl) + : super.fromJson(Event.getMapFromPayload(dbEntry.content), cl) { + final json = toJson(); + identifier = dbEntry.deviceId; + algorithms = json['algorithms'].cast(); + _verified = dbEntry.verified; + blocked = dbEntry.blocked; } - @override - Map toJson() { - final data = super.toJson(); - data['verified'] = verified; - data['blocked'] = blocked; - return data; + DeviceKeys.fromJson(Map json, Client cl) + : super.fromJson(Map.from(json), cl) { + final json = toJson(); + identifier = json['device_id']; + algorithms = json['algorithms'].cast(); } - KeyVerification startVerification(Client client) { + KeyVerification startVerification() { final request = KeyVerification( encryption: client.encryption, userId: userId, deviceId: deviceId); + request.start(); client.encryption.keyVerificationManager.addRequest(request); return request; diff --git a/pubspec.lock b/pubspec.lock index 7f54f84..5c8e154 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -36,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.6.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" async: dependency: transitive description: @@ -43,6 +50,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.4.1" + base58check: + dependency: "direct main" + description: + name: base58check + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" boolean_selector: dependency: transitive description: @@ -134,6 +148,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.4" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" code_builder: dependency: transitive description: @@ -163,7 +184,7 @@ packages: source: hosted version: "0.13.9" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto url: "https://pub.dartlang.org" @@ -183,6 +204,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.6" + encrypt: + dependency: "direct main" + description: + name: encrypt + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.2" ffi: dependency: transitive description: @@ -386,10 +414,10 @@ packages: description: path: "." ref: "1.x.y" - resolved-ref: f66975bd1b5cb1865eba5efe6e3a392aa5e396a5 + resolved-ref: "8e4fcccff7a2d4d0bd5142964db092bf45061905" url: "https://gitlab.com/famedly/libraries/dart-olm.git" source: git - version: "1.1.1" + version: "1.2.0" package_config: dependency: transitive description: @@ -397,6 +425,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.9.3" + password_hash: + dependency: "direct main" + description: + name: password_hash + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1fb7673..324dcd4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,10 @@ dependencies: html_unescape: ^1.0.1+3 moor: ^3.0.2 random_string: ^2.0.1 + encrypt: ^4.0.2 + crypto: ^2.1.4 + base58check: ^1.0.1 + password_hash: ^2.0.0 olm: git: @@ -33,4 +37,4 @@ dev_dependencies: moor_generator: ^3.0.0 build_runner: ^1.5.2 pedantic: ^1.9.0 - moor_ffi: ^0.5.0 \ No newline at end of file + moor_ffi: ^0.5.0 diff --git a/test.sh b/test.sh index 5efc157..2496656 100644 --- a/test.sh +++ b/test.sh @@ -2,5 +2,5 @@ pub run test -p vm pub run test_coverage pub global activate remove_from_coverage -pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '.g.dart$' +pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' genhtml -o coverage coverage/lcov.info || true diff --git a/test/client_test.dart b/test/client_test.dart index 39292b6..b0c8c91 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -127,7 +127,7 @@ void main() { } expect(sync.nextBatch == matrix.prevBatch, true); - expect(matrix.accountData.length, 3); + expect(matrix.accountData.length, 9); expect(matrix.getDirectChatFromUserId('@bob:example.com'), '!726s6s6q:example.com'); expect(matrix.rooms[1].directChatMatrixID, '@bob:example.com'); @@ -157,7 +157,7 @@ void main() { expect(matrix.presences['@alice:example.com'].presence.presence, PresenceType.online); expect(presenceCounter, 1); - expect(accountDataCounter, 3); + expect(accountDataCounter, 9); await Future.delayed(Duration(milliseconds: 50)); expect(matrix.userDeviceKeys.length, 4); expect(matrix.userDeviceKeys['@alice:example.com'].outdated, false); @@ -392,7 +392,7 @@ void main() { 'dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA' } } - }); + }, matrix); test('sendToDevice', () async { await matrix.sendToDevice( [deviceKeys], diff --git a/test/device_keys_list_test.dart b/test/device_keys_list_test.dart index 87d35d8..a98ff1b 100644 --- a/test/device_keys_list_test.dart +++ b/test/device_keys_list_test.dart @@ -20,6 +20,10 @@ import 'dart:convert'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:test/test.dart'; +import 'package:olm/olm.dart' as olm; + +import './fake_client.dart'; +import './fake_matrix_api.dart'; void main() { /// All Tests related to device keys @@ -41,33 +45,133 @@ void main() { } }, 'unsigned': {'device_display_name': "Alice's mobile phone"}, - 'verified': false, - 'blocked': true, - }; - var rawListJson = { - 'user_id': '@alice:example.com', - 'outdated': true, - 'device_keys': {'JLAFKJWSCS': rawJson}, }; - var userDeviceKeys = { - '@alice:example.com': DeviceKeysList.fromJson(rawListJson), - }; - var userDeviceKeyRaw = { - '@alice:example.com': rawListJson, - }; + final key = DeviceKeys.fromJson(rawJson, null); + await key.setVerified(false, false); + await key.setBlocked(true); + expect(json.encode(key.toJson()), json.encode(rawJson)); + expect(key.directVerified, false); + expect(key.blocked, true); - expect(json.encode(DeviceKeys.fromJson(rawJson).toJson()), - json.encode(rawJson)); - expect(json.encode(DeviceKeysList.fromJson(rawListJson).toJson()), - json.encode(rawListJson)); + rawJson = { + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['master'], + 'keys': { + 'ed25519:82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + '82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8', + }, + 'signatures': {}, + }; + final crossKey = CrossSigningKey.fromJson(rawJson, null); + expect(json.encode(crossKey.toJson()), json.encode(rawJson)); + expect(crossKey.usage.first, 'master'); + }); - var mapFromRaw = {}; - for (final rawListEntry in userDeviceKeyRaw.entries) { - mapFromRaw[rawListEntry.key] = - DeviceKeysList.fromJson(rawListEntry.value); - } - expect(mapFromRaw.toString(), userDeviceKeys.toString()); + var olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + print('[LibOlm] Failed to load LibOlm: ' + _.toString()); + } + print('[LibOlm] Enabled: $olmEnabled'); + + if (!olmEnabled) return; + + Client client; + + test('setupClient', () async { + client = await getClient(); + }); + + test('set blocked / verified', () async { + final key = + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']; + final masterKey = client.userDeviceKeys[client.userID].masterKey; + masterKey.setDirectVerified(true); + // we need to populate the ssss cache to be able to test signing easily + final handle = client.encryption.ssss.open(); + handle.unlock(recoveryKey: SSSS_KEY); + await handle.maybeCacheAll(); + + expect(key.verified, true); + await key.setBlocked(true); + expect(key.verified, false); + await key.setBlocked(false); + expect(key.directVerified, false); + expect(key.verified, true); // still verified via cross-sgining + + expect(masterKey.verified, true); + await masterKey.setBlocked(true); + expect(masterKey.verified, false); + await masterKey.setBlocked(false); + expect(masterKey.verified, true); + + FakeMatrixApi.calledEndpoints.clear(); + await key.setVerified(true); + await Future.delayed(Duration(milliseconds: 10)); + expect( + FakeMatrixApi.calledEndpoints.keys + .any((k) => k == '/client/r0/keys/signatures/upload'), + true); + expect(key.directVerified, true); + + FakeMatrixApi.calledEndpoints.clear(); + await key.setVerified(false); + await Future.delayed(Duration(milliseconds: 10)); + expect( + FakeMatrixApi.calledEndpoints.keys + .any((k) => k == '/client/r0/keys/signatures/upload'), + false); + expect(key.directVerified, false); + }); + + test('verification based on signatures', () async { + final user = client.userDeviceKeys[client.userID]; + user.masterKey.setDirectVerified(true); + expect(user.deviceKeys['GHTYAJCE'].crossVerified, true); + expect(user.deviceKeys['GHTYAJCE'].signed, true); + expect(user.getKey('GHTYAJCE').crossVerified, true); + expect(user.deviceKeys['OTHERDEVICE'].crossVerified, true); + expect(user.selfSigningKey.crossVerified, true); + expect( + user + .getKey('F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY') + .crossVerified, + true); + expect(user.userSigningKey.crossVerified, true); + expect(user.verified, UserVerifiedStatus.verified); + user.masterKey.setDirectVerified(false); + expect(user.deviceKeys['GHTYAJCE'].crossVerified, false); + expect(user.deviceKeys['OTHERDEVICE'].crossVerified, false); + expect(user.verified, UserVerifiedStatus.unknown); + user.masterKey.setDirectVerified(true); + user.deviceKeys['GHTYAJCE'].signatures.clear(); + expect(user.deviceKeys['GHTYAJCE'].verified, + true); // it's our own device, should be direct verified + expect( + user.deviceKeys['GHTYAJCE'].signed, false); // not verified for others + user.deviceKeys['OTHERDEVICE'].signatures.clear(); + expect(user.verified, UserVerifiedStatus.unknownDevice); + }); + + test('start verification', () async { + var req = client + .userDeviceKeys['@alice:example.com'].deviceKeys['JLAFKJWSCS'] + .startVerification(); + expect(req != null, true); + expect(req.room != null, false); + + req = + await client.userDeviceKeys['@alice:example.com'].startVerification(); + expect(req != null, true); + expect(req.room != null, true); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); }); }); } diff --git a/test/encryption/cross_signing_test.dart b/test/encryption/cross_signing_test.dart new file mode 100644 index 0000000..4ec212b --- /dev/null +++ b/test/encryption/cross_signing_test.dart @@ -0,0 +1,113 @@ +/* + * Ansible inventory script used at Famedly GmbH for managing many hosts + * Copyright (C) 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:famedlysdk/famedlysdk.dart'; +import 'package:test/test.dart'; +import 'package:olm/olm.dart' as olm; + +import '../fake_client.dart'; +import '../fake_matrix_api.dart'; + +void main() { + group('Cross Signing', () { + var olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + print('[LibOlm] Failed to load LibOlm: ' + _.toString()); + } + print('[LibOlm] Enabled: $olmEnabled'); + + if (!olmEnabled) return; + + Client client; + + test('setupClient', () async { + client = await getClient(); + }); + + test('basic things', () async { + expect(client.encryption.crossSigning.enabled, true); + }); + + test('selfSign', () async { + final key = client.userDeviceKeys[client.userID].masterKey; + key.setDirectVerified(false); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.crossSigning.selfSign(recoveryKey: SSSS_KEY); + expect(key.directVerified, true); + expect( + FakeMatrixApi.calledEndpoints + .containsKey('/client/r0/keys/signatures/upload'), + true); + expect(await client.encryption.crossSigning.isCached(), true); + }); + + test('signable', () async { + expect( + client.encryption.crossSigning + .signable([client.userDeviceKeys[client.userID].masterKey]), + true); + expect( + client.encryption.crossSigning.signable([ + client.userDeviceKeys[client.userID].deviceKeys[client.deviceID] + ]), + false); + expect( + client.encryption.crossSigning.signable( + [client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']]), + true); + expect( + client.encryption.crossSigning.signable([ + client.userDeviceKeys['@alice:example.com'].deviceKeys['JLAFKJWSCS'] + ]), + false); + }); + + test('sign', () async { + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.crossSigning.sign([ + client.userDeviceKeys[client.userID].masterKey, + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE'], + client.userDeviceKeys['@othertest:fakeServer.notExisting'].masterKey + ]); + var body = json.decode(FakeMatrixApi + .calledEndpoints['/client/r0/keys/signatures/upload'].first); + expect(body['@test:fakeServer.notExisting'].containsKey('OTHERDEVICE'), + true); + expect( + body['@test:fakeServer.notExisting'].containsKey( + client.userDeviceKeys[client.userID].masterKey.publicKey), + true); + expect( + body['@othertest:fakeServer.notExisting'].containsKey(client + .userDeviceKeys['@othertest:fakeServer.notExisting'] + .masterKey + .publicKey), + true); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/encryption/encrypt_decrypt_room_message_test.dart b/test/encryption/encrypt_decrypt_room_message_test.dart index 7227134..70d75fa 100644 --- a/test/encryption/encrypt_decrypt_room_message_test.dart +++ b/test/encryption/encrypt_decrypt_room_message_test.dart @@ -84,12 +84,15 @@ void main() { room: room, originServerTs: now, eventId: '\$event', + senderId: '@alice:example.com', ); final decryptedEvent = await client.encryption.decryptRoomEvent(roomId, encryptedEvent); expect(decryptedEvent.type, 'm.room.message'); expect(decryptedEvent.content['msgtype'], 'm.text'); expect(decryptedEvent.content['text'], 'Hello foxies!'); + await client.encryption + .decryptRoomEvent(roomId, encryptedEvent, store: true); }); test('dispose client', () async { diff --git a/test/encryption/encrypt_decrypt_to_device_test.dart b/test/encryption/encrypt_decrypt_to_device_test.dart index 0be0a19..5636e8b 100644 --- a/test/encryption/encrypt_decrypt_to_device_test.dart +++ b/test/encryption/encrypt_decrypt_to_device_test.dart @@ -61,17 +61,15 @@ void main() { ); await Future.delayed(Duration(milliseconds: 10)); - device = DeviceKeys( - userId: client.userID, - deviceId: client.deviceID, - algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], - keys: { + device = DeviceKeys.fromJson({ + 'user_id': client.userID, + 'device_id': client.deviceID, + 'algorithms': ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + 'keys': { 'curve25519:${client.deviceID}': client.identityKey, 'ed25519:${client.deviceID}': client.fingerprintKey, }, - verified: true, - blocked: false, - ); + }, client); }); test('encryptToDeviceMessage', () async { diff --git a/test/encryption/key_request_test.dart b/test/encryption/key_request_test.dart index 1ccf510..c7dbb9f 100644 --- a/test/encryption/key_request_test.dart +++ b/test/encryption/key_request_test.dart @@ -56,8 +56,9 @@ void main() { test('Create Request', () async { var matrix = await getClient(); final requestRoom = matrix.getRoomById('!726s6s6q:example.com'); - await matrix.encryption.keyManager - .request(requestRoom, 'sessionId', validSenderKey); + await matrix.encryption.keyManager.request( + requestRoom, 'sessionId', validSenderKey, + tryOnlineBackup: false); var foundEvent = false; for (var entry in FakeMatrixApi.calledEndpoints.entries) { final payload = jsonDecode(entry.value.first); @@ -85,10 +86,10 @@ void main() { FakeMatrixApi.calledEndpoints.clear(); await matrix .userDeviceKeys['@alice:example.com'].deviceKeys['OTHERDEVICE'] - .setBlocked(false, matrix); + .setBlocked(false); await matrix .userDeviceKeys['@alice:example.com'].deviceKeys['OTHERDEVICE'] - .setVerified(true, matrix); + .setVerified(true); // test a successful share var event = ToDeviceEvent( sender: '@alice:example.com', @@ -223,8 +224,9 @@ void main() { test('Receive shared keys', () async { var matrix = await getClient(); final requestRoom = matrix.getRoomById('!726s6s6q:example.com'); - await matrix.encryption.keyManager - .request(requestRoom, validSessionId, validSenderKey); + await matrix.encryption.keyManager.request( + requestRoom, validSessionId, validSenderKey, + tryOnlineBackup: false); final session = await matrix.encryption.keyManager .loadInboundGroupSession( @@ -279,8 +281,9 @@ void main() { false); // unknown device - await matrix.encryption.keyManager - .request(requestRoom, validSessionId, validSenderKey); + await matrix.encryption.keyManager.request( + requestRoom, validSessionId, validSenderKey, + tryOnlineBackup: false); matrix.encryption.keyManager.clearInboundGroupSessions(); event = ToDeviceEvent( sender: '@alice:example.com', @@ -304,8 +307,9 @@ void main() { false); // no encrypted content - await matrix.encryption.keyManager - .request(requestRoom, validSessionId, validSenderKey); + await matrix.encryption.keyManager.request( + requestRoom, validSessionId, validSenderKey, + tryOnlineBackup: false); matrix.encryption.keyManager.clearInboundGroupSessions(); event = ToDeviceEvent( sender: '@alice:example.com', diff --git a/test/encryption/key_verification_test.dart b/test/encryption/key_verification_test.dart index 1a9ddc9..66ccdee 100644 --- a/test/encryption/key_verification_test.dart +++ b/test/encryption/key_verification_test.dart @@ -16,12 +16,47 @@ * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/encryption.dart'; import 'package:test/test.dart'; import 'package:olm/olm.dart' as olm; import '../fake_client.dart'; +import '../fake_matrix_api.dart'; + +class MockSSSS extends SSSS { + MockSSSS(Encryption encryption) : super(encryption); + + bool requestedSecrets = false; + @override + Future maybeRequestAll(List devices) async { + requestedSecrets = true; + final handle = open(); + handle.unlock(recoveryKey: SSSS_KEY); + await handle.maybeCacheAll(); + } +} + +EventUpdate getLastSentEvent(KeyVerification req) { + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/')); + final type = entry.key.split('/')[6]; + final content = json.decode(entry.value.first); + return EventUpdate( + content: { + 'event_id': req.transactionId, + 'type': type, + 'content': content, + 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, + 'sender': req.client.userID, + }, + eventType: type, + type: 'timeline', + roomID: req.room.id, + ); +} void main() { /// All Tests related to the ChatTime @@ -38,70 +73,392 @@ void main() { if (!olmEnabled) return; - Client client; - Room room; - var updateCounter = 0; - KeyVerification keyVerification; + // key @othertest:fakeServer.notExisting + const otherPickledOlmAccount = + 'VWhVApbkcilKAEGppsPDf9nNVjaK8/IxT3asSR0sYg0S5KgbfE8vXEPwoiKBX2cEvwX3OessOBOkk+ZE7TTbjlrh/KEd31p8Wo+47qj0AP+Ky+pabnhi+/rTBvZy+gfzTqUfCxZrkzfXI9Op4JnP6gYmy7dVX2lMYIIs9WCO1jcmIXiXum5jnfXu1WLfc7PZtO2hH+k9CDKosOFaXRBmsu8k/BGXPSoWqUpvu6WpEG9t5STk4FeAzA'; + + Client client1; + Client client2; test('setupClient', () async { - client = await getClient(); - room = Room(id: '!localpart:server.abc', client: client); - keyVerification = KeyVerification( - encryption: client.encryption, - room: room, - userId: '@alice:example.com', - deviceId: 'ABCD', - onUpdate: () => updateCounter++, + client1 = await getClient(); + client2 = + Client('othertestclient', debug: true, httpClient: FakeMatrixApi()); + client2.database = client1.database; + await client2.checkServer('https://fakeServer.notExisting'); + client2.connect( + newToken: 'abc', + newUserID: '@othertest:fakeServer.notExisting', + newHomeserver: client2.api.homeserver, + newDeviceName: 'Text Matrix Client', + newDeviceID: 'FOXDEVICE', + newOlmAccount: otherPickledOlmAccount, ); + await Future.delayed(Duration(milliseconds: 10)); + client1.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers + }; + client2.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers + }; }); - test('acceptSas', () async { - await keyVerification.acceptSas(); - }); - test('acceptVerification', () async { - await keyVerification.acceptVerification(); - }); - test('cancel', () async { - await keyVerification.cancel('m.cancelcode'); - expect(keyVerification.canceled, true); - expect(keyVerification.canceledCode, 'm.cancelcode'); - expect(keyVerification.canceledReason, null); - }); - test('handlePayload', () async { - await keyVerification.handlePayload('m.key.verification.request', { - 'from_device': 'AliceDevice2', - 'methods': ['m.sas.v1'], - 'timestamp': 1559598944869, - 'transaction_id': 'S0meUniqueAndOpaqueString' + test('Run emoji / number verification', () async { + // for a full run we test in-room verification in a cleartext room + // because then we can easily intercept the payloads and inject in the other client + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(false); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + KeyVerification req2; + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + req2 = req; }); - await keyVerification.handlePayload('m.key.verification.start', { - 'from_device': 'BobDevice1', - 'method': 'm.sas.v1', - 'transaction_id': 'S0meUniqueAndOpaqueString' + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + await Future.delayed(Duration(milliseconds: 10)); + await sub.cancel(); + expect(req2 != null, true); + + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.waitingAccept); + + // send start + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send accept + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // receive last key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + + // compare emoji + expect(req1.state, KeyVerificationState.askSas); + expect(req2.state, KeyVerificationState.askSas); + expect(req1.sasTypes[0], 'emoji'); + expect(req1.sasTypes[1], 'decimal'); + expect(req2.sasTypes[0], 'emoji'); + expect(req2.sasTypes[1], 'decimal'); + // compare emoji + final emoji1 = req1.sasEmojis; + final emoji2 = req2.sasEmojis; + for (var i = 0; i < 7; i++) { + expect(emoji1[i].emoji, emoji2[i].emoji); + expect(emoji1[i].name, emoji2[i].name); + } + // compare numbers + final numbers1 = req1.sasNumbers; + final numbers2 = req2.sasNumbers; + for (var i = 0; i < 3; i++) { + expect(numbers1[i], numbers2[i]); + } + + // alright, they match + + // send mac + FakeMatrixApi.calledEndpoints.clear(); + await req1.acceptSas(); + evt = getLastSentEvent(req1); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + expect(req1.state, KeyVerificationState.waitingSas); + + // send mac + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptSas(); + evt = getLastSentEvent(req2); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.state, KeyVerificationState.done); + expect(req2.state, KeyVerificationState.done); + expect( + client1.userDeviceKeys[client2.userID].deviceKeys[client2.deviceID] + .directVerified, + true); + expect( + client2.userDeviceKeys[client1.userID].deviceKeys[client1.deviceID] + .directVerified, + true); + await client1.encryption.keyVerificationManager.cleanup(); + await client2.encryption.keyVerificationManager.cleanup(); + }); + + test('ask SSSS start', () async { + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true); + await client1.database.clearSSSSCache(client1.id); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + expect(req1.state, KeyVerificationState.askSSSS); + await req1.openSSSS(recoveryKey: SSSS_KEY); + await Future.delayed(Duration(milliseconds: 10)); + expect(req1.state, KeyVerificationState.waitingAccept); + + await req1.cancel(); + await client1.encryption.keyVerificationManager.cleanup(); + }); + + test('ask SSSS end', () async { + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(false); + // the other one has to have their master key verified to trigger asking for ssss + client2.userDeviceKeys[client2.userID].masterKey.setDirectVerified(true); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + KeyVerification req2; + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + req2 = req; }); - await keyVerification.handlePayload('m.key.verification.cancel', { - 'code': 'm.user', - 'reason': 'User rejected the key verification request', - 'transaction_id': 'S0meUniqueAndOpaqueString' + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + await Future.delayed(Duration(milliseconds: 10)); + await sub.cancel(); + expect(req2 != null, true); + + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.waitingAccept); + + // send start + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send accept + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // receive last key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + + // compare emoji + expect(req1.state, KeyVerificationState.askSas); + expect(req2.state, KeyVerificationState.askSas); + // compare emoji + final emoji1 = req1.sasEmojis; + final emoji2 = req2.sasEmojis; + for (var i = 0; i < 7; i++) { + expect(emoji1[i].emoji, emoji2[i].emoji); + expect(emoji1[i].name, emoji2[i].name); + } + // compare numbers + final numbers1 = req1.sasNumbers; + final numbers2 = req2.sasNumbers; + for (var i = 0; i < 3; i++) { + expect(numbers1[i], numbers2[i]); + } + + // alright, they match + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true); + await client1.database.clearSSSSCache(client1.id); + + // send mac + FakeMatrixApi.calledEndpoints.clear(); + await req1.acceptSas(); + evt = getLastSentEvent(req1); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + expect(req1.state, KeyVerificationState.waitingSas); + + // send mac + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptSas(); + evt = getLastSentEvent(req2); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.state, KeyVerificationState.askSSSS); + expect(req2.state, KeyVerificationState.done); + + await req1.openSSSS(recoveryKey: SSSS_KEY); + await Future.delayed(Duration(milliseconds: 10)); + expect(req1.state, KeyVerificationState.done); + + client1.encryption.ssss = MockSSSS(client1.encryption); + (client1.encryption.ssss as MockSSSS).requestedSecrets = false; + await client1.database.clearSSSSCache(client1.id); + await req1.maybeRequestSSSSSecrets(); + await Future.delayed(Duration(milliseconds: 10)); + expect((client1.encryption.ssss as MockSSSS).requestedSecrets, true); + // delay for 12 seconds to be sure no other tests clear the ssss cache + await Future.delayed(Duration(seconds: 12)); + + await client1.encryption.keyVerificationManager.cleanup(); + await client2.encryption.keyVerificationManager.cleanup(); + }); + + test('reject verification', () async { + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(false); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + KeyVerification req2; + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + req2 = req; }); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + await Future.delayed(Duration(milliseconds: 10)); + await sub.cancel(); + + FakeMatrixApi.calledEndpoints.clear(); + await req2.rejectVerification(); + evt = getLastSentEvent(req2); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + expect(req1.state, KeyVerificationState.error); + expect(req2.state, KeyVerificationState.error); + + await client1.encryption.keyVerificationManager.cleanup(); + await client2.encryption.keyVerificationManager.cleanup(); }); - test('rejectSas', () async { - await keyVerification.rejectSas(); + + test('reject sas', () async { + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(false); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + KeyVerification req2; + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + req2 = req; + }); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + await Future.delayed(Duration(milliseconds: 10)); + await sub.cancel(); + expect(req2 != null, true); + + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.waitingAccept); + + // send start + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send accept + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req1); + + // send key + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + evt = getLastSentEvent(req2); + + // receive last key + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + + await req1.acceptSas(); + FakeMatrixApi.calledEndpoints.clear(); + await req2.rejectSas(); + evt = getLastSentEvent(req2); + await client1.encryption.keyVerificationManager.handleEventUpdate(evt); + expect(req1.state, KeyVerificationState.error); + expect(req2.state, KeyVerificationState.error); + + await client1.encryption.keyVerificationManager.cleanup(); + await client2.encryption.keyVerificationManager.cleanup(); }); - test('rejectVerification', () async { - await keyVerification.rejectVerification(); - }); - test('start', () async { - await keyVerification.start(); - }); - test('verifyActivity', () async { - final verified = await keyVerification.verifyActivity(); - expect(verified, true); - keyVerification?.dispose(); + + test('other device accepted', () async { + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(false); + final req1 = + await client1.userDeviceKeys[client2.userID].startVerification(); + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + KeyVerification req2; + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + req2 = req; + }); + await client2.encryption.keyVerificationManager.handleEventUpdate(evt); + await Future.delayed(Duration(milliseconds: 10)); + await sub.cancel(); + expect(req2 != null, true); + + await client2.encryption.keyVerificationManager + .handleEventUpdate(EventUpdate( + content: { + 'event_id': req2.transactionId, + 'type': 'm.key.verification.ready', + 'content': { + 'methods': ['m.sas.v1'], + 'from_device': 'SOMEOTHERDEVICE', + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': req2.transactionId, + }, + }, + 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, + 'sender': client2.userID, + }, + eventType: 'm.key.verification.ready', + type: 'timeline', + roomID: req2.room.id, + )); + expect(req2.state, KeyVerificationState.error); + + await req2.cancel(); + await client1.encryption.keyVerificationManager.cleanup(); + await client2.encryption.keyVerificationManager.cleanup(); }); test('dispose client', () async { - await client.dispose(closeDatabase: true); + await client1.dispose(closeDatabase: true); + await client2.dispose(closeDatabase: true); }); }); } diff --git a/test/encryption/online_key_backup_test.dart b/test/encryption/online_key_backup_test.dart new file mode 100644 index 0000000..0a3b842 --- /dev/null +++ b/test/encryption/online_key_backup_test.dart @@ -0,0 +1,73 @@ +/* + * Ansible inventory script used at Famedly GmbH for managing many hosts + * Copyright (C) 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 'package:famedlysdk/famedlysdk.dart'; +import 'package:test/test.dart'; +import 'package:olm/olm.dart' as olm; + +import '../fake_client.dart'; + +void main() { + group('Online Key Backup', () { + var olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + print('[LibOlm] Failed to load LibOlm: ' + _.toString()); + } + print('[LibOlm] Enabled: $olmEnabled'); + + if (!olmEnabled) return; + + Client client; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final senderKey = 'JBG7ZaPn54OBC7TuIEiylW3BZ+7WcGQhFBPB9pogbAg'; + + test('setupClient', () async { + client = await getClient(); + }); + + test('basic things', () async { + expect(client.encryption.keyManager.enabled, true); + expect(await client.encryption.keyManager.isCached(), false); + final handle = client.encryption.ssss.open(); + handle.unlock(recoveryKey: SSSS_KEY); + await handle.maybeCacheAll(); + expect(await client.encryption.keyManager.isCached(), true); + }); + + test('load key', () async { + client.encryption.keyManager.clearInboundGroupSessions(); + await client.encryption.keyManager + .request(client.getRoomById(roomId), sessionId, senderKey); + expect( + client.encryption.keyManager + .getInboundGroupSession(roomId, sessionId, senderKey) != + null, + true); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/encryption/ssss_test.dart b/test/encryption/ssss_test.dart new file mode 100644 index 0000000..a0d5b94 --- /dev/null +++ b/test/encryption/ssss_test.dart @@ -0,0 +1,398 @@ +/* + * Ansible inventory script used at Famedly GmbH for managing many hosts + * Copyright (C) 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:typed_data'; +import 'dart:convert'; + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/matrix_api.dart'; +import 'package:famedlysdk/encryption.dart'; +import 'package:test/test.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:olm/olm.dart' as olm; + +import '../fake_client.dart'; +import '../fake_matrix_api.dart'; + +void main() { + group('SSSS', () { + var olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + print('[LibOlm] Failed to load LibOlm: ' + _.toString()); + } + print('[LibOlm] Enabled: $olmEnabled'); + + if (!olmEnabled) return; + + Client client; + + test('setupClient', () async { + client = await getClient(); + }); + + test('basic things', () async { + expect(client.encryption.ssss.defaultKeyId, + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3'); + }); + + test('encrypt / decrypt', () { + final key = Uint8List.fromList(SecureRandom(32).bytes); + + final enc = SSSS.encryptAes('secret foxies', key, 'name'); + final dec = SSSS.decryptAes(enc, key, 'name'); + expect(dec, 'secret foxies'); + }); + + test('store', () async { + final handle = client.encryption.ssss.open(); + var failed = false; + try { + handle.unlock(passphrase: 'invalid'); + } catch (_) { + failed = true; + } + expect(failed, true); + expect(handle.isUnlocked, false); + failed = false; + try { + handle.unlock(recoveryKey: 'invalid'); + } catch (_) { + failed = true; + } + expect(failed, true); + expect(handle.isUnlocked, false); + handle.unlock(passphrase: SSSS_PASSPHRASE); + handle.unlock(recoveryKey: SSSS_KEY); + expect(handle.isUnlocked, true); + FakeMatrixApi.calledEndpoints.clear(); + await handle.store('best animal', 'foxies'); + // alright, since we don't properly sync we will manually have to update + // account_data for this test + final content = FakeMatrixApi + .calledEndpoints[ + '/client/r0/user/%40test%3AfakeServer.notExisting/account_data/best+animal'] + .first; + client.accountData['best animal'] = BasicEvent.fromJson({ + 'type': 'best animal', + 'content': json.decode(content), + }); + expect(await handle.getStored('best animal'), 'foxies'); + }); + + test('cache', () async { + final handle = + client.encryption.ssss.open('m.cross_signing.self_signing'); + handle.unlock(recoveryKey: SSSS_KEY); + expect( + (await client.encryption.ssss + .getCached('m.cross_signing.self_signing')) != + null, + false); + expect( + (await client.encryption.ssss + .getCached('m.cross_signing.user_signing')) != + null, + false); + await handle.getStored('m.cross_signing.self_signing'); + expect( + (await client.encryption.ssss + .getCached('m.cross_signing.self_signing')) != + null, + true); + await handle.maybeCacheAll(); + expect( + (await client.encryption.ssss + .getCached('m.cross_signing.user_signing')) != + null, + true); + expect( + (await client.encryption.ssss.getCached('m.megolm_backup.v1')) != + null, + true); + }); + + test('make share requests', () async { + final key = + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']; + key.setDirectVerified(true); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.request('some.type', [key]); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + true); + }); + + test('answer to share requests', () async { + var event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.cross_signing.self_signing', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + true); + + // now test some fail scenarios + + // not by us + event = ToDeviceEvent( + sender: '@someotheruser:example.org', + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.cross_signing.self_signing', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + false); + + // secret not cached + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.unknown.secret', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + false); + + // is a cancelation + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.request', + content: { + 'action': 'request_cancellation', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.cross_signing.self_signing', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + false); + + // device not verified + final key = + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']; + key.setDirectVerified(false); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.request', + content: { + 'action': 'request', + 'requesting_device_id': 'OTHERDEVICE', + 'name': 'm.cross_signing.self_signing', + 'request_id': '1', + }, + ); + FakeMatrixApi.calledEndpoints.clear(); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (k) => k.startsWith('/client/r0/sendToDevice/m.room.encrypted')), + false); + key.setDirectVerified(true); + }); + + test('receive share requests', () async { + final key = + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']; + key.setDirectVerified(true); + final handle = + client.encryption.ssss.open('m.cross_signing.self_signing'); + handle.unlock(recoveryKey: SSSS_KEY); + + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('best animal', [key]); + var event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached('best animal'), 'foxies!'); + + // test the different validators + for (final type in [ + 'm.cross_signing.self_signing', + 'm.cross_signing.user_signing', + 'm.megolm_backup.v1' + ]) { + final secret = await handle.getStored(type); + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request(type, [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': + client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': secret, + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached(type), secret); + } + + // test different fail scenarios + + // not encrypted + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached('best animal'), null); + + // unknown request id + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': 'invalid', + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached('best animal'), null); + + // not from a device we sent the request to + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': 'invalid', + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached('best animal'), null); + + // secret not a string + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('best animal', [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': 42, + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect(await client.encryption.ssss.getCached('best animal'), null); + + // validator doesn't check out + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.request('m.megolm_backup.v1', [key]); + event = ToDeviceEvent( + sender: client.userID, + type: 'm.secret.send', + content: { + 'request_id': client.encryption.ssss.pendingShareRequests.keys.first, + 'secret': 'foxies!', + }, + encryptedContent: { + 'sender_key': key.curve25519Key, + }, + ); + await client.encryption.ssss.handleToDeviceEvent(event); + expect( + await client.encryption.ssss.getCached('m.megolm_backup.v1'), null); + }); + + test('request all', () async { + final key = + client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE']; + key.setDirectVerified(true); + await client.database.clearSSSSCache(client.id); + client.encryption.ssss.pendingShareRequests.clear(); + await client.encryption.ssss.maybeRequestAll([key]); + expect(client.encryption.ssss.pendingShareRequests.length, 3); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/fake_client.dart b/test/fake_client.dart index 9fb3837..af2c39a 100644 --- a/test/fake_client.dart +++ b/test/fake_client.dart @@ -21,6 +21,9 @@ import 'package:famedlysdk/famedlysdk.dart'; import 'fake_matrix_api.dart'; import 'fake_database.dart'; +const SSSS_PASSPHRASE = 'nae7ahDiequ7ohniufah3ieS2je1thohX4xeeka7aixohsho9O'; +const SSSS_KEY = 'EsT9 RzbW VhPW yqNp cC7j ViiW 5TZB LuY4 ryyv 9guN Ysmr WDPH'; + // key @test:fakeServer.notExisting const pickledOlmAccount = 'N2v1MkIFGcl0mQpo2OCwSopxPQJ0wnl7oe7PKiT4141AijfdTIhRu+ceXzXKy3Kr00nLqXtRv7kid6hU4a+V0rfJWLL0Y51+3Rp/ORDVnQy+SSeo6Fn4FHcXrxifJEJ0djla5u98fBcJ8BSkhIDmtXRPi5/oJAvpiYn+8zMjFHobOeZUAxYR0VfQ9JzSYBsSovoQ7uFkNks1M4EDUvHtuyg3RxViwdNxs3718fyAqQ/VSwbXsY0Nl+qQbF+nlVGHenGqk5SuNl1P6e1PzZxcR0IfXA94Xij1Ob5gDv5YH4UCn9wRMG0abZsQP0YzpDM0FLaHSCyo9i5JD/vMlhH+nZWrgAzPPCTNGYewNV8/h3c+VyJh8ZTx/fVi6Yq46Fv+27Ga2ETRZ3Qn+Oyx6dLBjnBZ9iUvIhqpe2XqaGA1PopOz8iDnaZitw'; diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 8e9c01a..2e26b50 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -78,6 +78,11 @@ class FakeMatrixApi extends MockClient { action.contains('/state/m.room.member/')) { res = {'displayname': ''}; return Response(json.encode(res), 200); + } else if (method == 'PUT' && + action.contains( + '/client/r0/rooms/%211234%3AfakeServer.notExisting/send/')) { + res = {'event_id': '\$event${FakeMatrixApi.eventCounter++}'}; + return Response(json.encode(res), 200); } else { res = { 'errcode': 'M_UNRECOGNIZED', @@ -528,6 +533,75 @@ class FakeMatrixApi extends MockClient { }, 'type': 'm.direct' }, + { + 'type': 'm.secret_storage.default_key', + 'content': {'key': '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3'} + }, + { + 'type': 'm.secret_storage.key.0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3', + 'content': { + 'algorithm': 'm.secret_storage.v1.aes-hmac-sha2', + 'passphrase': { + 'algorithm': 'm.pbkdf2', + 'iterations': 500000, + 'salt': 'F4jJ80mr0Fc8mRwU9JgA3lQDyjPuZXQL' + }, + 'iv': 'HjbTgIoQH2pI7jQo19NUzA==', + 'mac': 'QbJjQzDnAggU0cM4RBnDxw2XyarRGjdahcKukP9xVlk=' + } + }, + { + 'type': 'm.cross_signing.master', + 'content': { + 'encrypted': { + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { + 'iv': 'eIb2IITxtmcq+1TrT8D5eQ==', + 'ciphertext': + 'lWRTPo5qxf4LAVwVPzGHOyMcP181n7bb9/B0lvkLDC2Oy4DvAL0eLx2x3bY=', + 'mac': 'Ynx89tIxPkx0o6ljMgxszww17JOgB4tg4etmNnMC9XI=' + } + } + } + }, + { + 'type': 'm.cross_signing.self_signing', + 'content': { + 'encrypted': { + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { + 'iv': 'YqU2XIjYulYZl+bkZtGgVw==', + 'ciphertext': + 'kM2TSoy/jR/4d357ZoRPbpPypxQl6XRLo3FsEXz+f7vIOp82GeRp28RYb3k=', + 'mac': 'F+DZa5tAFmWsYSryw5EuEpzTmmABRab4GETkM85bGGo=' + } + } + } + }, + { + 'type': 'm.cross_signing.user_signing', + 'content': { + 'encrypted': { + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { + 'iv': 'D7AM3LXFu7ZlyGOkR+OeqQ==', + 'ciphertext': + 'bYA2+OMgsO6QB1E31aY+ESAWrT0fUBTXqajy4qmL7bVDSZY4Uj64EXNbHuA=', + 'mac': 'j2UtyPo/UBSoiaQCWfzCiRZXp3IRt0ZZujuXgUMjnw4=' + } + } + } + }, + { + 'type': 'm.megolm_backup.v1', + 'content': { + 'encrypted': { + '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { + 'iv': 'cL/0MJZaiEd3fNU+I9oJrw==', + 'ciphertext': + 'WL73Pzdk5wZdaaSpaeRH0uZYKcxkuV8IS6Qa2FEfA1+vMeRLuHcWlXbMX0w=', + 'mac': '+xozp909S6oDX8KRV8D8ZFVRyh7eEYQpPP76f+DOsnw=' + } + } + } + } ] }, 'to_device': { @@ -1473,6 +1547,65 @@ class FakeMatrixApi extends MockClient { 'event_format': 'client', 'event_fields': ['type', 'content', 'sender'] }, + '/client/unstable/room_keys/version': (var req) => { + 'algorithm': 'm.megolm_backup.v1.curve25519-aes-sha2', + 'auth_data': { + 'public_key': 'GXYaxqhNhUK28zUdxOmEsFRguz+PzBsDlTLlF0O0RkM', + 'signatures': {}, + }, + 'count': 0, + 'etag': '0', + 'version': '5', + }, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5': + (var req) => { + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5': + (var req) => { + 'sessions': { + 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU': { + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': + 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }, + }, + }, + '/client/unstable/room_keys/keys?version=5': (var req) => { + 'rooms': { + '!726s6s6q:example.com': { + 'sessions': { + 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU': { + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': + 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }, + }, + }, + }, + }, }, 'POST': { '/client/r0/delete_devices': (var req) => {}, @@ -1683,7 +1816,30 @@ class FakeMatrixApi extends MockClient { 'ed25519:GHTYAJCE': 'gjL//fyaFHADt9KBADGag8g7F8Up78B/K1zXeiEPLJo' }, - 'signatures': {}, + 'signatures': { + '@test:fakeServer.notExisting': { + 'ed25519:F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY': + 'Q4/55vZjEJD7M2EC40bgZqd9Zuy/4C75UPVopJdXeioQVaKtFf6EF0nUUuql0yD+r3hinsZcock0wO6Q2xcoAQ', + }, + }, + }, + 'OTHERDEVICE': { + 'user_id': '@test:fakeServer.notExisting', + 'device_id': 'OTHERDEVICE', + 'algorithms': [ + 'm.olm.v1.curve25519-aes-sha2', + 'm.megolm.v1.aes-sha2' + ], + 'keys': { + 'curve25519:OTHERDEVICE': 'blah', + 'ed25519:OTHERDEVICE': 'blah' + }, + 'signatures': { + '@test:fakeServer.notExisting': { + 'ed25519:F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY': + 'o7ucKPWrF2VKx7wYqP1f+aw4QohLMz7kX+SIw6aWCYsLC3XyIlg8rX/7QQ9B8figCVnRK7IjtjWvQodBCfWCAA', + }, + }, }, }, '@othertest:fakeServer.notExisting': { @@ -1704,6 +1860,73 @@ class FakeMatrixApi extends MockClient { }, }, }, + 'master_keys': { + '@test:fakeServer.notExisting': { + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['master'], + 'keys': { + 'ed25519:82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + '82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8', + }, + 'signatures': {}, + }, + '@othertest:fakeServer.notExisting': { + 'user_id': '@othertest:fakeServer.notExisting', + 'usage': ['master'], + 'keys': { + 'ed25519:master': 'master', + }, + 'signatures': {}, + }, + }, + 'self_signing_keys': { + '@test:fakeServer.notExisting': { + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['self_signing'], + 'keys': { + 'ed25519:F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY': + 'F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY', + }, + 'signatures': { + '@test:fakeServer.notExisting': { + 'ed25519:82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + 'afkrbGvPn5Zb5zc7Lk9cz2skI3QrzI/L0st1GS+/GATxNjMzc6vKmGu7r9cMb1GJxy4RdeUpfH3L7Fs/fNL1Dw', + }, + }, + }, + '@othertest:fakeServer.notExisting': { + 'user_id': '@othertest:fakeServer.notExisting', + 'usage': ['self_signing'], + 'keys': { + 'ed25519:self_signing': 'self_signing', + }, + 'signatures': {}, + }, + }, + 'user_signing_keys': { + '@test:fakeServer.notExisting': { + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['user_signing'], + 'keys': { + 'ed25519:0PiwulzJ/RU86LlzSSZ8St80HUMN3dqjKa/orIJoA0g': + '0PiwulzJ/RU86LlzSSZ8St80HUMN3dqjKa/orIJoA0g', + }, + 'signatures': { + '@test:fakeServer.notExisting': { + 'ed25519:82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + 'pvgbZxEbllaElhpiRnb7/uOIUhrglvHCFnpoxr3/5ZrWa0EK/uaefhex9eEV4uBLrHjHg2ymwdNaM7ap9+sBBg', + }, + }, + }, + '@othertest:fakeServer.notExisting': { + 'user_id': '@othertest:fakeServer.notExisting', + 'usage': ['user_signing'], + 'keys': { + 'ed25519:user_signing': 'user_signing', + }, + 'signatures': {}, + }, + }, }, '/client/r0/register': (var req) => { 'user_id': '@testuser:example.com', @@ -1751,6 +1974,9 @@ class FakeMatrixApi extends MockClient { '/client/r0/rooms/!localpart%3Aserver.abc/ban': (var reqI) => {}, '/client/r0/rooms/!localpart%3Aserver.abc/unban': (var reqI) => {}, '/client/r0/rooms/!localpart%3Aserver.abc/invite': (var reqI) => {}, + '/client/r0/keys/device_signing/upload': (var reqI) => {}, + '/client/r0/keys/signatures/upload': (var reqI) => {'failures': {}}, + '/client/unstable/room_keys/version': (var reqI) => {'version': '5'}, }, 'PUT': { '/client/r0/presence/${Uri.encodeComponent('@alice:example.com')}/status': @@ -1797,8 +2023,14 @@ class FakeMatrixApi extends MockClient { (var req) => {}, '/client/r0/user/%40alice%3Aexample.com/account_data/test.account.data': (var req) => {}, + '/client/r0/user/%40test%3AfakeServer.notExisting/account_data/best+animal': + (var req) => {}, '/client/r0/user/%40alice%3Aexample.com/rooms/1234/account_data/test.account.data': (var req) => {}, + '/client/r0/user/%40test%3AfakeServer.notExisting/account_data/m.direct': + (var req) => {}, + '/client/r0/user/%40othertest%3AfakeServer.notExisting/account_data/m.direct': + (var req) => {}, '/client/r0/profile/%40alice%3Aexample.com/displayname': (var reqI) => {}, '/client/r0/profile/%40alice%3Aexample.com/avatar_url': (var reqI) => {}, '/client/r0/profile/%40test%3AfakeServer.notExisting/avatar_url': @@ -1831,6 +2063,21 @@ class FakeMatrixApi extends MockClient { (var reqI) => {}, '/client/r0/directory/list/room/!localpart%3Aexample.com': (var req) => {}, + '/client/unstable/room_keys/version/5': (var req) => {}, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5': + (var req) => { + 'etag': 'asdf', + 'count': 1, + }, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5': + (var req) => { + 'etag': 'asdf', + 'count': 1, + }, + '/client/unstable/room_keys/keys?version=5': (var req) => { + 'etag': 'asdf', + 'count': 1, + }, }, 'DELETE': { '/unknown/token': (var req) => {'errcode': 'M_UNKNOWN_TOKEN'}, @@ -1843,6 +2090,21 @@ class FakeMatrixApi extends MockClient { (var req) => {}, '/client/r0/user/%40alice%3Aexample.com/rooms/%21localpart%3Aexample.com/tags/testtag': (var req) => {}, + '/client/unstable/room_keys/version/5': (var req) => {}, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5': + (var req) => { + 'etag': 'asdf', + 'count': 1, + }, + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5': + (var req) => { + 'etag': 'asdf', + 'count': 1, + }, + '/client/unstable/room_keys/keys?version=5': (var req) => { + 'etag': 'asdf', + 'count': 1, + }, }, }; } diff --git a/test/matrix_api_test.dart b/test/matrix_api_test.dart index 0d3fa02..a165628 100644 --- a/test/matrix_api_test.dart +++ b/test/matrix_api_test.dart @@ -18,7 +18,7 @@ import 'dart:typed_data'; import 'package:famedlysdk/matrix_api.dart'; -import 'package:famedlysdk/matrix_api/model/matrix_device_keys.dart'; +import 'package:famedlysdk/matrix_api/model/matrix_keys.dart'; import 'package:famedlysdk/matrix_api/model/filter.dart'; import 'package:famedlysdk/matrix_api/model/matrix_exception.dart'; import 'package:famedlysdk/matrix_api/model/presence_content.dart'; @@ -1116,6 +1116,83 @@ void main() { matrixApi.homeserver = matrixApi.accessToken = null; }); + test('uploadDeviceSigningKeys', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final masterKey = MatrixCrossSigningKey.fromJson({ + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['master'], + 'keys': { + 'ed25519:82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + '82mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8', + }, + 'signatures': {}, + }); + final selfSigningKey = MatrixCrossSigningKey.fromJson({ + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['self_signing'], + 'keys': { + 'ed25519:F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY': + 'F9ypFzgbISXCzxQhhSnXMkc1vq12Luna3Nw5rqViOJY', + }, + 'signatures': {}, + }); + final userSigningKey = MatrixCrossSigningKey.fromJson({ + 'user_id': '@test:fakeServer.notExisting', + 'usage': ['user_signing'], + 'keys': { + 'ed25519:0PiwulzJ/RU86LlzSSZ8St80HUMN3dqjKa/orIJoA0g': + '0PiwulzJ/RU86LlzSSZ8St80HUMN3dqjKa/orIJoA0g', + }, + 'signatures': {}, + }); + await matrixApi.uploadDeviceSigningKeys( + masterKey: masterKey, + selfSigningKey: selfSigningKey, + userSigningKey: userSigningKey); + }); + test('uploadKeySignatures', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final key1 = MatrixDeviceKeys.fromJson({ + 'user_id': '@alice:example.com', + 'device_id': 'JLAFKJWSCS', + 'algorithms': ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + 'keys': { + 'curve25519:JLAFKJWSCS': + '3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI', + 'ed25519:JLAFKJWSCS': 'lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI' + }, + 'signatures': { + '@alice:example.com': { + 'ed25519:JLAFKJWSCS': + 'dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA' + } + }, + 'unsigned': {'device_display_name': 'Alices mobile phone'}, + }); + final key2 = MatrixDeviceKeys.fromJson({ + 'user_id': '@alice:example.com', + 'device_id': 'JLAFKJWSCS', + 'algorithms': ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + 'keys': { + 'curve25519:JLAFKJWSCS': + '3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI', + 'ed25519:JLAFKJWSCS': 'lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI' + }, + 'signatures': { + '@alice:example.com': {'ed25519:OTHERDEVICE': 'OTHERSIG'} + }, + 'unsigned': {'device_display_name': 'Alices mobile phone'}, + }); + final ret = await matrixApi.uploadKeySignatures([key1, key2]); + expect( + FakeMatrixApi.api['POST']['/client/r0/keys/signatures/upload']({}), + ret.toJson(), + ); + }); test('requestPushers', () async { matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); matrixApi.accessToken = '1234'; @@ -1514,5 +1591,194 @@ void main() { matrixApi.homeserver = matrixApi.accessToken = null; }); + test('createRoomKeysBackup', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final algorithm = RoomKeysAlgorithmType.v1Curve25519AesSha2; + final authData = { + 'public_key': 'GXYaxqhNhUK28zUdxOmEsFRguz+PzBsDlTLlF0O0RkM', + 'signatures': {}, + }; + final ret = await matrixApi.createRoomKeysBackup(algorithm, authData); + expect( + FakeMatrixApi.api['POST'] + ['/client/unstable/room_keys/version']({})['version'], + ret); + }); + test('getRoomKeysBackup', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final ret = await matrixApi.getRoomKeysBackup(); + expect(FakeMatrixApi.api['GET']['/client/unstable/room_keys/version']({}), + ret.toJson()); + }); + test('updateRoomKeysBackup', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final algorithm = RoomKeysAlgorithmType.v1Curve25519AesSha2; + final authData = { + 'public_key': 'GXYaxqhNhUK28zUdxOmEsFRguz+PzBsDlTLlF0O0RkM', + 'signatures': {}, + }; + await matrixApi.updateRoomKeysBackup('5', algorithm, authData); + }); + test('deleteRoomKeysBackup', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + await matrixApi.deleteRoomKeysBackup('5'); + }); + test('storeRoomKeysSingleKey', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final session = RoomKeysSingleKey.fromJson({ + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }); + final ret = await matrixApi.storeRoomKeysSingleKey( + roomId, sessionId, '5', session); + expect( + FakeMatrixApi.api['PUT'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5']({}), + ret.toJson()); + }); + test('getRoomKeysSingleKey', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final ret = await matrixApi.getRoomKeysSingleKey(roomId, sessionId, '5'); + expect( + FakeMatrixApi.api['GET'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5']({}), + ret.toJson()); + }); + test('deleteRoomKeysSingleKey', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final ret = + await matrixApi.deleteRoomKeysSingleKey(roomId, sessionId, '5'); + expect( + FakeMatrixApi.api['DELETE'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}/${Uri.encodeComponent('ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU')}?version=5']({}), + ret.toJson()); + }); + test('storeRoomKeysRoom', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final session = RoomKeysRoom.fromJson({ + 'sessions': { + sessionId: { + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }, + }, + }); + final ret = await matrixApi.storeRoomKeysRoom(roomId, '5', session); + expect( + FakeMatrixApi.api['PUT'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5']({}), + ret.toJson()); + }); + test('getRoomKeysRoom', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final ret = await matrixApi.getRoomKeysRoom(roomId, '5'); + expect( + FakeMatrixApi.api['GET'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5']({}), + ret.toJson()); + }); + test('deleteRoomKeysRoom', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final ret = await matrixApi.deleteRoomKeysRoom(roomId, '5'); + expect( + FakeMatrixApi.api['DELETE'][ + '/client/unstable/room_keys/keys/${Uri.encodeComponent('!726s6s6q:example.com')}?version=5']({}), + ret.toJson()); + }); + test('storeRoomKeys', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final roomId = '!726s6s6q:example.com'; + final sessionId = 'ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU'; + final session = RoomKeys.fromJson({ + 'rooms': { + roomId: { + 'sessions': { + sessionId: { + 'first_message_index': 0, + 'forwarded_count': 0, + 'is_verified': true, + 'session_data': { + 'ephemeral': 'fwRxYh+seqLykz5mQCLypJ4/59URdcFJ2s69OU1dGRc', + 'ciphertext': + '19jkQYlbgdP+VL9DH3qY/Dvpk6onJZgf+6frZFl1TinPCm9OMK9AZZLuM1haS9XLAUK1YsREgjBqfl6T+Tq8JlJ5ONZGg2Wttt24sGYc0iTMZJ8rXcNDeKMZhM96ETyjufJSeYoXLqifiVLDw9rrVBmNStF7PskYp040em+0OZ4pF85Cwsdf7l9V7MMynzh9BoXqVUCBiwT03PNYH9AEmNUxXX+6ZwCpe/saONv8MgGt5uGXMZIK29phA3D8jD6uV/WOHsB8NjHNq9FrfSEAsl+dAcS4uiYie4BKSSeQN+zGAQqu1MMW4OAdxGOuf8WpIINx7n+7cKQfxlmc/Cgg5+MmIm2H0oDwQ+Xu7aSxp1OCUzbxQRdjz6+tnbYmZBuH0Ov2RbEvC5tDb261LRqKXpub0llg5fqKHl01D0ahv4OAQgRs5oU+4mq+H2QGTwIFGFqP9tCRo0I+aICawpxYOfoLJpFW6KvEPnM2Lr3sl6Nq2fmkz6RL5F7nUtzxN8OKazLQpv8DOYzXbi7+ayEsqS0/EINetq7RfCqgjrEUgfNWYuFXWqvUT8lnxLdNu+8cyrJqh1UquFjXWTw1kWcJ0pkokVeBtK9YysCnF1UYh/Iv3rl2ZoYSSLNtuvMSYlYHggZ8xV8bz9S3X2/NwBycBiWIy5Ou/OuSX7trIKgkkmda0xjBWEM1a2acVuqu2OFbMn2zFxm2a3YwKP//OlIgMg', + 'mac': 'QzKV/fgAs4U', + }, + }, + }, + }, + }, + }); + final ret = await matrixApi.storeRoomKeys('5', session); + expect( + FakeMatrixApi.api['PUT'] + ['/client/unstable/room_keys/keys?version=5']({}), + ret.toJson()); + }); + test('getRoomKeys', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final ret = await matrixApi.getRoomKeys('5'); + expect( + FakeMatrixApi.api['GET'] + ['/client/unstable/room_keys/keys?version=5']({}), + ret.toJson()); + }); + test('deleteRoomKeys', () async { + matrixApi.homeserver = Uri.parse('https://fakeserver.notexisting'); + matrixApi.accessToken = '1234'; + + final ret = await matrixApi.deleteRoomKeys('5'); + expect( + FakeMatrixApi.api['DELETE'] + ['/client/unstable/room_keys/keys?version=5']({}), + ret.toJson()); + }); }); } diff --git a/test_driver/famedlysdk_test.dart b/test_driver/famedlysdk_test.dart index d230747..1dc8f9d 100644 --- a/test_driver/famedlysdk_test.dart +++ b/test_driver/famedlysdk_test.dart @@ -107,7 +107,7 @@ void test() async { assert(!testClientB .userDeviceKeys[testUserA].deviceKeys[testClientA.deviceID].blocked); await testClientA.userDeviceKeys[testUserB].deviceKeys[testClientB.deviceID] - .setVerified(true, testClientA); + .setVerified(true); print('++++ Check if own olm device is verified by default ++++'); assert(testClientA.userDeviceKeys.containsKey(testUserA));