diff --git a/lib/encryption.dart b/lib/encryption.dart index 6ebb4f74..f53d2a5e 100644 --- a/lib/encryption.dart +++ b/lib/encryption.dart @@ -22,3 +22,4 @@ export 'encryption/encryption.dart'; export 'encryption/key_manager.dart'; export 'encryption/ssss.dart'; export 'encryption/utils/key_verification.dart'; +export 'encryption/utils/bootstrap.dart'; diff --git a/lib/encryption/cross_signing.dart b/lib/encryption/cross_signing.dart index a44a85e1..eff2fad3 100644 --- a/lib/encryption/cross_signing.dart +++ b/lib/encryption/cross_signing.dart @@ -24,8 +24,8 @@ import 'package:olm/olm.dart' as olm; import '../famedlysdk.dart'; import 'encryption.dart'; -const SELF_SIGNING_KEY = 'm.cross_signing.self_signing'; -const USER_SIGNING_KEY = 'm.cross_signing.user_signing'; +const SELF_SIGNING_KEY = EventTypes.CrossSigningSelfSigning; +const USER_SIGNING_KEY = EventTypes.CrossSigningUserSigning; const MASTER_KEY = 'm.cross_signing.master'; class CrossSigning { diff --git a/lib/encryption/encryption.dart b/lib/encryption/encryption.dart index e962379c..e28b8ee0 100644 --- a/lib/encryption/encryption.dart +++ b/lib/encryption/encryption.dart @@ -30,6 +30,7 @@ import 'key_manager.dart'; import 'key_verification_manager.dart'; import 'olm_manager.dart'; import 'ssss.dart'; +import 'utils/bootstrap.dart'; class Encryption { final Client client; @@ -69,6 +70,11 @@ class Encryption { _backgroundTasks(); // start the background tasks } + Bootstrap bootstrap({void Function() onUpdate}) => Bootstrap( + encryption: this, + onUpdate: onUpdate, + ); + void handleDeviceOneTimeKeysCount(Map countJson) { runInRoot(() => olmManager.handleDeviceOneTimeKeysCount(countJson)); } diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index a7c9a9d9..57923b08 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -30,7 +30,7 @@ import '../matrix_api/utils/logs.dart'; import '../src/utils/run_in_background.dart'; import '../src/utils/run_in_root.dart'; -const MEGOLM_KEY = 'm.megolm_backup.v1'; +const MEGOLM_KEY = EventTypes.MegolmBackup; class KeyManager { final Encryption encryption; diff --git a/lib/encryption/ssss.dart b/lib/encryption/ssss.dart index 3d230ec9..ddb51876 100644 --- a/lib/encryption/ssss.dart +++ b/lib/encryption/ssss.dart @@ -30,20 +30,28 @@ import '../famedlysdk.dart'; import '../matrix_api.dart'; import '../src/database/database.dart'; import '../matrix_api/utils/logs.dart'; +import '../src/utils/run_in_background.dart'; import 'encryption.dart'; -const CACHE_TYPES = [ - 'm.cross_signing.self_signing', - 'm.cross_signing.user_signing', - 'm.megolm_backup.v1' -]; +const CACHE_TYPES = { + EventTypes.CrossSigningSelfSigning, + EventTypes.CrossSigningUserSigning, + EventTypes.MegolmBackup, +}; + 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 +const SSSS_KEY_LENGTH = 32; +const PBKDF2_DEFAULT_ITERATIONS = 500000; +const PBKDF2_SALT_LENGTH = 64; + +Map _deepcopy(Map data) { + return json.decode(json.encode(data)); +} class SSSS { final Encryption encryption; @@ -129,17 +137,16 @@ class SSSS { } } - if (result.length != - OLM_RECOVERY_KEY_PREFIX.length + OLM_PRIVATE_KEY_LENGTH + 1) { + if (result.length != OLM_RECOVERY_KEY_PREFIX.length + SSSS_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)); + OLM_RECOVERY_KEY_PREFIX.length + SSSS_KEY_LENGTH)); } static Uint8List keyFromPassphrase(String passphrase, _PassphraseInfo info) { - if (info.algorithm != 'm.pbkdf2') { + if (info.algorithm != AlgorithmTypes.pbkdf2) { throw 'Unknown algorithm'; } final generator = PBKDF2(hashAlgorithm: sha512); @@ -156,15 +163,84 @@ class SSSS { } String get defaultKeyId { - final keyData = client.accountData['m.secret_storage.default_key']; + final keyData = client.accountData[EventTypes.SecretStorageDefaultKey]; if (keyData == null || !(keyData.content['key'] is String)) { return null; } return keyData.content['key']; } + Future setDefaultKeyId(String keyId) async { + await client.setAccountData( + client.userID, EventTypes.SecretStorageDefaultKey, { + 'key': keyId, + }); + } + BasicEvent getKey(String keyId) { - return client.accountData['m.secret_storage.key.${keyId}']; + return client.accountData[EventTypes.secretStorageKey(keyId)]; + } + + bool isKeyValid(String keyId) { + final keyData = getKey(keyId); + if (keyData == null) { + return false; + } + return keyData.content['algorithm'] == + AlgorithmTypes.secretStorageV1AesHmcSha2; + } + + Future createKey([String passphrase]) async { + Uint8List privateKey; + final content = {}; + if (passphrase != null) { + // we need to derive the key off of the passphrase + content['passphrase'] = {}; + content['passphrase']['algorithm'] = AlgorithmTypes.pbkdf2; + content['passphrase']['salt'] = base64 + .encode(SecureRandom(PBKDF2_SALT_LENGTH).bytes); // generate salt + content['passphrase']['iterations'] = PBKDF2_DEFAULT_ITERATIONS; + content['passphrase']['bits'] = SSSS_KEY_LENGTH * 8; + privateKey = await runInBackground( + _keyFromPassphrase, + _KeyFromPassphraseArgs( + passphrase: passphrase, + info: _PassphraseInfo( + algorithm: content['passphrase']['algorithm'], + salt: content['passphrase']['salt'], + iterations: content['passphrase']['iterations'], + bits: content['passphrase']['bits'], + ), + )); + } else { + // we need to just generate a new key from scratch + privateKey = Uint8List.fromList(SecureRandom(SSSS_KEY_LENGTH).bytes); + } + // now that we have the private key, let's create the iv and mac + final encrypted = encryptAes(ZERO_STR, privateKey, ''); + content['iv'] = encrypted.iv; + content['mac'] = encrypted.mac; + content['algorithm'] = AlgorithmTypes.secretStorageV1AesHmcSha2; + + const KEYID_BYTE_LENGTH = 24; + + // make sure we generate a unique key id + var keyId = base64.encode(SecureRandom(KEYID_BYTE_LENGTH).bytes); + while (getKey(keyId) != null) { + keyId = base64.encode(SecureRandom(KEYID_BYTE_LENGTH).bytes); + } + + final accountDataType = EventTypes.secretStorageKey(keyId); + // noooow we set the account data + final waitForAccountData = client.onSync.stream.firstWhere((syncUpdate) => + syncUpdate.accountData + .any((accountData) => accountData.type == accountDataType)); + await client.setAccountData(client.userID, accountDataType, content); + await waitForAccountData; + + final key = open(keyId); + key.setPrivateKey(privateKey); + return key; } bool checkKey(Uint8List key, BasicEvent keyData) { @@ -234,12 +310,19 @@ class SSSS { return decrypted; } - Future store( - String type, String secret, String keyId, Uint8List key) async { + Future store(String type, String secret, String keyId, Uint8List key, + {bool add = false}) async { final triggerCacheCallback = _cacheCallbacks.containsKey(type) && await getCached(type) == null; final encrypted = encryptAes(secret, key, type); - final content = { + Map content; + if (add && client.accountData[type] != null) { + content = _deepcopy(client.accountData[type].content); + if (!(content['encrypted'] is Map)) { + content['encrypted'] = {}; + } + } + content ??= { 'encrypted': {}, }; content['encrypted'][keyId] = { @@ -259,6 +342,29 @@ class SSSS { } } + Future validateAndStripOtherKeys( + String type, String secret, String keyId, Uint8List key) async { + if (await getStored(type, keyId, key) != secret) { + throw 'Secrets do not match up!'; + } + // now remove all other keys + final content = _deepcopy(client.accountData[type].content); + final otherKeys = + Set.from(content['encrypted'].keys.where((k) => k != keyId)); + content['encrypted'].removeWhere((k, v) => otherKeys.contains(k)); + // yes, we are paranoid... + if (await getStored(type, keyId, key) != secret) { + throw 'Secrets do not match up!'; + } + // store the thing in your account data + await client.setAccountData(client.userID, type, content); + if (CACHE_TYPES.contains(type) && client.database != null) { + // cache the thing + await client.database.storeSSSSCache(client.id, type, keyId, + content['encrypted'][keyId]['ciphertext'], secret); + } + } + Future maybeCacheAll(String keyId, Uint8List key) async { for (final type in CACHE_TYPES) { final secret = await getCached(type); @@ -512,15 +618,27 @@ class OpenSSSS { bool get isUnlocked => privateKey != null; - void unlock({String passphrase, String recoveryKey}) { - if (passphrase != null) { - privateKey = SSSS.keyFromPassphrase( - passphrase, - _PassphraseInfo( + Future unlock( + {String passphrase, String recoveryKey, String keyOrPassphrase}) async { + if (keyOrPassphrase != null) { + try { + await unlock(recoveryKey: keyOrPassphrase); + } catch (_) { + await unlock(passphrase: keyOrPassphrase); + } + return; + } else if (passphrase != null) { + privateKey = await runInBackground( + _keyFromPassphrase, + _KeyFromPassphraseArgs( + passphrase: passphrase, + info: _PassphraseInfo( algorithm: keyData.content['passphrase']['algorithm'], salt: keyData.content['passphrase']['salt'], iterations: keyData.content['passphrase']['iterations'], - bits: keyData.content['passphrase']['bits'])); + bits: keyData.content['passphrase']['bits'], + ), + )); } else if (recoveryKey != null) { privateKey = SSSS.decodeRecoveryKey(recoveryKey); } else { @@ -533,15 +651,36 @@ class OpenSSSS { } } + void setPrivateKey(Uint8List key) { + if (!ssss.checkKey(key, keyData)) { + throw 'Invalid key'; + } + privateKey = 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 store(String type, String secret, {bool add = false}) async { + await ssss.store(type, secret, keyId, privateKey, add: add); + } + + Future validateAndStripOtherKeys(String type, String secret) async { + await ssss.validateAndStripOtherKeys(type, secret, keyId, privateKey); } Future maybeCacheAll() async { await ssss.maybeCacheAll(keyId, privateKey); } } + +class _KeyFromPassphraseArgs { + final String passphrase; + final _PassphraseInfo info; + _KeyFromPassphraseArgs({this.passphrase, this.info}); +} + +Uint8List _keyFromPassphrase(_KeyFromPassphraseArgs args) { + return SSSS.keyFromPassphrase(args.passphrase, args.info); +} diff --git a/lib/encryption/utils/bootstrap.dart b/lib/encryption/utils/bootstrap.dart new file mode 100644 index 00000000..c3e95a54 --- /dev/null +++ b/lib/encryption/utils/bootstrap.dart @@ -0,0 +1,543 @@ +/* + * 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:convert'; +import 'dart:typed_data'; + +import 'package:canonical_json/canonical_json.dart'; +import 'package:olm/olm.dart' as olm; + +import '../encryption.dart'; +import '../ssss.dart'; +import '../cross_signing.dart'; +import '../key_manager.dart'; +import '../../famedlysdk.dart'; +import '../../matrix_api/utils/logs.dart'; + +enum BootstrapState { + loading, // loading + askWipeSsss, // existing SSSS found, should we wipe it? + askUseExistingSsss, // ask if an existing SSSS should be userDeviceKeys + askBadSsss, // SSSS is in a bad state, continue with potential dataloss? + askUnlockSsss, // Ask to unlock all the SSSS keys + askNewSsss, // Ask for new SSSS key / passphrase + openExistingSsss, // Open an existing SSSS key + askWipeCrossSigning, // Ask if cross signing should be wiped + askSetupCrossSigning, // Ask if cross signing should be set up + askWipeOnlineKeyBackup, // Ask if online key backup should be wiped + askSetupOnlineKeyBackup, // Ask if the online key backup should be set up + error, // error + done, // done +} + +class Bootstrap { + final Encryption encryption; + Client get client => encryption.client; + void Function() onUpdate; + BootstrapState get state => _state; + BootstrapState _state = BootstrapState.loading; + Map oldSsssKeys; + OpenSSSS newSsssKey; + Map secretMap; + + Bootstrap({this.encryption, this.onUpdate}) { + if (analyzeSecrets().isNotEmpty) { + state = BootstrapState.askWipeSsss; + } else { + state = BootstrapState.askNewSsss; + } + } + + // cache the secret analyzing so that we don't drop stuff a different client sets during bootstrapping + Map> _secretsCache; + Map> analyzeSecrets() { + if (_secretsCache != null) { + // deep-copy so that we can do modifications + final newSecrets = >{}; + for (final s in _secretsCache.entries) { + newSecrets[s.key] = Set.from(s.value); + } + return newSecrets; + } + final secrets = >{}; + for (final entry in client.accountData.entries) { + final type = entry.key; + final event = entry.value; + if (!(event.content['encrypted'] is Map)) { + continue; + } + final validKeys = {}; + final invalidKeys = {}; + for (final keyEntry in event.content['encrypted'].entries) { + final key = keyEntry.key; + final value = keyEntry.value; + if (!(value is Map)) { + // we don't add the key to invalidKeys as this was not a proper secret anyways! + continue; + } + if (!(value['iv'] is String) || + !(value['ciphertext'] is String) || + !(value['mac'] is String)) { + invalidKeys.add(key); + continue; + } + if (!encryption.ssss.isKeyValid(key)) { + invalidKeys.add(key); + continue; + } + validKeys.add(key); + } + if (validKeys.isEmpty && invalidKeys.isEmpty) { + continue; // this didn't contain any keys anyways! + } + // if there are no valid keys and only invalid keys then the validKeys set will be empty + // from that we know that there were errors with this secret and that we won't be able to migrate it + secrets[type] = validKeys; + } + _secretsCache = secrets; + return analyzeSecrets(); + } + + Set badSecrets() { + final secrets = analyzeSecrets(); + secrets.removeWhere((k, v) => v.isNotEmpty); + return Set.from(secrets.keys); + } + + String mostUsedKey(Map> secrets) { + final usage = {}; + for (final keys in secrets.values) { + for (final key in keys) { + if (!usage.containsKey(key)) { + usage[key] = 0; + } + usage[key]++; + } + } + final entriesList = usage.entries.toList(); + entriesList.sort((a, b) => a.value.compareTo(b.value)); + return entriesList.first.key; + } + + Set allNeededKeys() { + final secrets = analyzeSecrets(); + secrets.removeWhere( + (k, v) => v.isEmpty); // we don't care about the failed secrets here + final keys = {}; + final defaultKeyId = encryption.ssss.defaultKeyId; + final removeKey = (String key) { + final sizeBefore = secrets.length; + secrets.removeWhere((k, v) => v.contains(key)); + return sizeBefore - secrets.length; + }; + // first we want to try the default key id + if (defaultKeyId != null) { + if (removeKey(defaultKeyId) > 0) { + keys.add(defaultKeyId); + } + } + // now we re-try as long as we have keys for all secrets + while (secrets.isNotEmpty) { + final key = mostUsedKey(secrets); + removeKey(key); + keys.add(key); + } + return keys; + } + + void wipeSsss(bool wipe) { + if (state != BootstrapState.askWipeSsss) { + throw Exception('Wrong State'); + } + if (wipe) { + state = BootstrapState.askNewSsss; + } else if (encryption.ssss.defaultKeyId != null && + encryption.ssss.isKeyValid(encryption.ssss.defaultKeyId)) { + state = BootstrapState.askUseExistingSsss; + } else if (badSecrets().isNotEmpty) { + state = BootstrapState.askBadSsss; + } else { + migrateOldSsss(); + } + } + + void useExistingSsss(bool use) { + if (state != BootstrapState.askUseExistingSsss) { + throw Exception('Wrong State'); + } + if (use) { + newSsssKey = encryption.ssss.open(encryption.ssss.defaultKeyId); + state = BootstrapState.openExistingSsss; + } else if (badSecrets().isNotEmpty) { + state = BootstrapState.askBadSsss; + } else { + migrateOldSsss(); + } + } + + void ignoreBadSecrets(bool ignore) { + if (state != BootstrapState.askBadSsss) { + throw Exception('Wrong State'); + } + if (ignore) { + migrateOldSsss(); + } else { + // that's it, folks. We can't do anything here + state = BootstrapState.error; + } + } + + void migrateOldSsss() { + final keys = allNeededKeys(); + oldSsssKeys = {}; + try { + for (final key in keys) { + oldSsssKeys[key] = encryption.ssss.open(key); + } + } catch (e) { + // very bad + Logs.error( + '[Bootstrapping] Error construction ssss key: ' + e.toString()); + state = BootstrapState.error; + return; + } + state = BootstrapState.askUnlockSsss; + } + + void unlockedSsss() { + if (state != BootstrapState.askUnlockSsss) { + throw Exception('Wrong State'); + } + state = BootstrapState.askNewSsss; + } + + Future newSsss([String passphrase]) async { + if (state != BootstrapState.askNewSsss) { + throw Exception('Wrong State'); + } + state = BootstrapState.loading; + try { + newSsssKey = await encryption.ssss.createKey(passphrase); + if (oldSsssKeys != null) { + // alright, we have to re-encrypt old secrets with the new key + final secrets = analyzeSecrets(); + final removeKey = (String key) { + final s = secrets.entries + .where((e) => e.value.contains(key)) + .map((e) => e.key) + .toSet(); + secrets.removeWhere((k, v) => v.contains(key)); + return s; + }; + secretMap = {}; + for (final entry in oldSsssKeys.entries) { + final key = entry.value; + final keyId = entry.key; + if (!key.isUnlocked) { + continue; + } + for (final s in removeKey(keyId)) { + secretMap[s] = await key.getStored(s); + await newSsssKey.store(s, secretMap[s], add: true); + } + } + // alright, we re-encrypted all the secrets. We delete the dead weight only *after* we set our key to the default key + } + final updatedAccountData = client.onSync.stream.firstWhere((syncUpdate) => + syncUpdate.accountData.any((accountData) => + accountData.type == EventTypes.SecretStorageDefaultKey)); + await encryption.ssss.setDefaultKeyId(newSsssKey.keyId); + await updatedAccountData; + if (oldSsssKeys != null) { + for (final entry in secretMap.entries) { + await newSsssKey.validateAndStripOtherKeys(entry.key, entry.value); + } + // and make super sure we have everything cached + await newSsssKey.maybeCacheAll(); + } + } catch (e, s) { + Logs.error( + '[Bootstrapping] Error trying to migrate old secrets: ' + + e.toString(), + s); + state = BootstrapState.error; + return; + } + // alright, we successfully migrated all secrets, if needed + + checkCrossSigning(); + } + + void openExistingSsss() { + if (state != BootstrapState.openExistingSsss) { + throw 'Bad State'; + } + if (!newSsssKey.isUnlocked) { + throw 'Key not unlocked'; + } + checkCrossSigning(); + } + + void checkCrossSigning() { + // so, let's see if we have cross signing set up + if (encryption.crossSigning.enabled) { + // cross signing present, ask for wipe + state = BootstrapState.askWipeCrossSigning; + return; + } + // no cross signing present + state = BootstrapState.askSetupCrossSigning; + } + + void wipeCrossSigning(bool wipe) { + if (state != BootstrapState.askWipeCrossSigning) { + throw 'Bad State'; + } + if (wipe) { + state = BootstrapState.askSetupCrossSigning; + } else { + checkOnlineKeyBackup(); + } + } + + Future askSetupCrossSigning( + {bool setupMasterKey = false, + bool setupSelfSigningKey = false, + bool setupUserSigningKey = false}) async { + if (state != BootstrapState.askSetupCrossSigning) { + throw 'Bad State'; + } + if (!setupMasterKey && !setupSelfSigningKey && !setupUserSigningKey) { + checkOnlineKeyBackup(); + return; + } + Uint8List masterSigningKey; + final secretsToStore = {}; + MatrixCrossSigningKey masterKey; + MatrixCrossSigningKey selfSigningKey; + MatrixCrossSigningKey userSigningKey; + String masterPub; + if (setupMasterKey) { + final master = olm.PkSigning(); + try { + masterSigningKey = master.generate_seed(); + masterPub = master.init_with_seed(masterSigningKey); + final json = { + 'user_id': client.userID, + 'usage': ['master'], + 'keys': { + 'ed25519:$masterPub': masterPub, + }, + }; + masterKey = MatrixCrossSigningKey.fromJson(json); + secretsToStore[MASTER_KEY] = base64.encode(masterSigningKey); + } finally { + master.free(); + } + } else { + masterSigningKey = + base64.decode(await newSsssKey.getStored(MASTER_KEY) ?? ''); + if (masterSigningKey == null || masterSigningKey.isEmpty) { + // no master signing key :( + throw 'No master key'; + } + final master = olm.PkSigning(); + try { + masterPub = master.init_with_seed(masterSigningKey); + } finally { + master.free(); + } + } + final _sign = (Map object) { + final keyObj = olm.PkSigning(); + try { + keyObj.init_with_seed(masterSigningKey); + return keyObj.sign(String.fromCharCodes(canonicalJson.encode(object))); + } finally { + keyObj.free(); + } + }; + if (setupSelfSigningKey) { + final selfSigning = olm.PkSigning(); + try { + final selfSigningPriv = selfSigning.generate_seed(); + final selfSigningPub = selfSigning.init_with_seed(selfSigningPriv); + final json = { + 'user_id': client.userID, + 'usage': ['self_signing'], + 'keys': { + 'ed25519:$selfSigningPub': selfSigningPub, + }, + }; + final signature = _sign(json); + json['signatures'] = { + client.userID: { + 'ed25519:$masterPub': signature, + }, + }; + selfSigningKey = MatrixCrossSigningKey.fromJson(json); + secretsToStore[SELF_SIGNING_KEY] = base64.encode(selfSigningPriv); + } finally { + selfSigning.free(); + } + } + if (setupUserSigningKey) { + final userSigning = olm.PkSigning(); + try { + final userSigningPriv = userSigning.generate_seed(); + final userSigningPub = userSigning.init_with_seed(userSigningPriv); + final json = { + 'user_id': client.userID, + 'usage': ['user_signing'], + 'keys': { + 'ed25519:$userSigningPub': userSigningPub, + }, + }; + final signature = _sign(json); + json['signatures'] = { + client.userID: { + 'ed25519:$masterPub': signature, + }, + }; + userSigningKey = MatrixCrossSigningKey.fromJson(json); + secretsToStore[USER_SIGNING_KEY] = base64.encode(userSigningPriv); + } finally { + userSigning.free(); + } + } + try { + // upload the keys! + await client.uiaRequestBackground( + (Map auth) => client.uploadDeviceSigningKeys( + masterKey: masterKey, + selfSigningKey: selfSigningKey, + userSigningKey: userSigningKey, + auth: auth, + )); + // aaaand set the SSSS secrets + final futures = >[]; + if (masterKey != null) { + futures.add(client.onSync.stream.firstWhere((syncUpdate) => + client.userDeviceKeys.containsKey(client.userID) && + client.userDeviceKeys[client.userID].masterKey != null && + client.userDeviceKeys[client.userID].masterKey.ed25519Key == + masterKey.publicKey)); + } + for (final entry in secretsToStore.entries) { + futures.add(client.onSync.stream.firstWhere((syncUpdate) => syncUpdate + .accountData + .any((accountData) => accountData.type == entry.key))); + await newSsssKey.store(entry.key, entry.value); + } + for (final f in futures) { + await f; + } + final keysToSign = []; + if (masterKey != null) { + if (client.userDeviceKeys[client.userID].masterKey.ed25519Key != + masterKey.publicKey) { + throw 'ERROR: New master key does not match up!'; + } + await client.userDeviceKeys[client.userID].masterKey + .setVerified(true, false); + keysToSign.add(client.userDeviceKeys[client.userID].masterKey); + } + // and sign ourself! + if (selfSigningKey != null) { + keysToSign.add( + client.userDeviceKeys[client.userID].deviceKeys[client.deviceID]); + } + await encryption.crossSigning.sign(keysToSign); + } catch (e, s) { + Logs.error( + '[Bootstrapping] Error setting up cross signing: ' + e.toString(), s); + state = BootstrapState.error; + return; + } + + checkOnlineKeyBackup(); + } + + void checkOnlineKeyBackup() { + // check if we have online key backup set up + if (encryption.keyManager.enabled) { + state = BootstrapState.askWipeOnlineKeyBackup; + return; + } + state = BootstrapState.askSetupOnlineKeyBackup; + } + + void wipeOnlineKeyBackup(bool wipe) { + if (state != BootstrapState.askWipeOnlineKeyBackup) { + throw 'Bad State'; + } + if (wipe) { + state = BootstrapState.askSetupOnlineKeyBackup; + } else { + state = BootstrapState.done; + } + } + + Future askSetupOnlineKeyBackup(bool setup) async { + if (state != BootstrapState.askSetupOnlineKeyBackup) { + throw 'Bad State'; + } + if (!setup) { + state = BootstrapState.done; + return; + } + final keyObj = olm.PkDecryption(); + String pubKey; + Uint8List privKey; + try { + pubKey = keyObj.generate_key(); + privKey = keyObj.get_private_key(); + } finally { + keyObj.free(); + } + try { + // create the new backup version + await client.createRoomKeysBackup( + RoomKeysAlgorithmType.v1Curve25519AesSha2, + { + 'public_key': pubKey, + }, + ); + // store the secret + await newSsssKey.store(MEGOLM_KEY, base64.encode(privKey)); + // and finally set all megolm keys as needing to be uploaded again + await client.database?.markInboundGroupSessionsAsNeedingUpload(client.id); + } catch (e, s) { + Logs.error( + '[Bootstrapping] Error setting up online key backup: ' + e.toString(), + s); + state = BootstrapState.error; + return; + } + state = BootstrapState.done; + } + + set state(BootstrapState newState) { + if (state != BootstrapState.error) { + _state = newState; + } + if (onUpdate != null) { + onUpdate(); + } + } +} diff --git a/lib/encryption/utils/key_verification.dart b/lib/encryption/utils/key_verification.dart index a1d84a22..846caff6 100644 --- a/lib/encryption/utils/key_verification.dart +++ b/lib/encryption/utils/key_verification.dart @@ -362,7 +362,10 @@ class KeyVerification { } Future openSSSS( - {String passphrase, String recoveryKey, bool skip = false}) async { + {String passphrase, + String recoveryKey, + String keyOrPassphrase, + bool skip = false}) async { final next = () { if (_nextAction == 'request') { sendStart(); @@ -378,8 +381,11 @@ class KeyVerification { next(); return; } - final handle = encryption.ssss.open('m.cross_signing.user_signing'); - await handle.unlock(passphrase: passphrase, recoveryKey: recoveryKey); + final handle = encryption.ssss.open(EventTypes.CrossSigningUserSigning); + await handle.unlock( + passphrase: passphrase, + recoveryKey: recoveryKey, + keyOrPassphrase: keyOrPassphrase); await handle.maybeCacheAll(); next(); } diff --git a/lib/famedlysdk.dart b/lib/famedlysdk.dart index a6617e9c..ff17ff48 100644 --- a/lib/famedlysdk.dart +++ b/lib/famedlysdk.dart @@ -30,6 +30,7 @@ export 'src/utils/receipt.dart'; export 'src/utils/states_map.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; +export 'src/utils/uia_request.dart'; export 'src/client.dart'; export 'src/event.dart'; export 'src/room.dart'; diff --git a/lib/matrix_api/matrix_api.dart b/lib/matrix_api/matrix_api.dart index 05be4254..a33f3e7f 100644 --- a/lib/matrix_api/matrix_api.dart +++ b/lib/matrix_api/matrix_api.dart @@ -1470,14 +1470,16 @@ class MatrixApi { MatrixCrossSigningKey masterKey, MatrixCrossSigningKey selfSigningKey, MatrixCrossSigningKey userSigningKey, + Map auth, }) async { await request( RequestType.POST, - '/client/r0/keys/device_signing/upload', + '/client/unstable/keys/device_signing/upload', data: { - 'master_key': masterKey.toJson(), - 'self_signing_key': selfSigningKey.toJson(), - 'user_signing_key': userSigningKey.toJson(), + if (masterKey != null) 'master_key': masterKey.toJson(), + if (selfSigningKey != null) 'self_signing_key': selfSigningKey.toJson(), + if (userSigningKey != null) 'user_signing_key': userSigningKey.toJson(), + if (auth != null) 'auth': auth, }, ); } diff --git a/lib/matrix_api/model/algorithm_types.dart b/lib/matrix_api/model/algorithm_types.dart index 457b4799..cc657d7b 100644 --- a/lib/matrix_api/model/algorithm_types.dart +++ b/lib/matrix_api/model/algorithm_types.dart @@ -23,4 +23,5 @@ abstract class AlgorithmTypes { 'm.secret_storage.v1.aes-hmac-sha2'; static const String megolmBackupV1Curve25519AesSha2 = 'm.megolm_backup.v1.curve25519-aes-sha2'; + static const String pbkdf2 = 'm.pbkdf2'; } diff --git a/lib/matrix_api/model/event_types.dart b/lib/matrix_api/model/event_types.dart index fbd4c655..c1b9c356 100644 --- a/lib/matrix_api/model/event_types.dart +++ b/lib/matrix_api/model/event_types.dart @@ -41,4 +41,10 @@ abstract class EventTypes { static const String CallCandidates = 'm.call.candidates'; static const String CallHangup = 'm.call.hangup'; static const String Unknown = 'm.unknown'; + + static const String CrossSigningSelfSigning = 'm.cross_signing.self_signing'; + static const String CrossSigningUserSigning = 'm.cross_signing.user_signing'; + static const String MegolmBackup = 'm.megolm_backup.v1'; + static const String SecretStorageDefaultKey = 'm.secret_storage.default_key'; + static String secretStorageKey(String keyId) => 'm.secret_storage.key.$keyId'; } diff --git a/lib/src/client.dart b/lib/src/client.dart index b52c5920..5a1c0065 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -34,6 +34,7 @@ import '../matrix_api/utils/logs.dart'; import 'utils/matrix_file.dart'; import 'utils/room_update.dart'; import 'utils/to_device_event.dart'; +import 'utils/uia_request.dart'; typedef RoomSorter = int Function(Room a, Room b); @@ -409,6 +410,24 @@ class Client extends MatrixApi { } } + Future uiaRequestBackground( + Future Function(Map auth) request) { + final completer = Completer(); + UiaRequest uia; + uia = UiaRequest( + request: request, + onDone: () { + if (uia.done) { + completer.complete(uia.result); + } else if (uia.fail) { + completer.completeError(uia.error); + } + }, + ); + onUiaRequest.add(uia); + return completer.future; + } + /// Returns the user's own displayname and avatar url. In Matrix it is possible that /// one user can have different displaynames and avatar urls in different rooms. So /// this endpoint first checks if the profile is the same in all rooms. If not, the @@ -590,6 +609,11 @@ class Client extends MatrixApi { final StreamController onKeyVerificationRequest = StreamController.broadcast(); + /// When the library calls an endpoint that needs UIA the `UiaRequest` is passed down this screen. + /// The client can open a UIA prompt based on this. + final StreamController onUiaRequest = + StreamController.broadcast(); + /// How long should the app wait until it retrys the synchronisation after /// an error? int syncErrorTimeoutSec = 3; diff --git a/lib/src/database/database.g.dart b/lib/src/database/database.g.dart index 52a2ac4a..fba8593b 100644 --- a/lib/src/database/database.g.dart +++ b/lib/src/database/database.g.dart @@ -6347,6 +6347,15 @@ abstract class _$Database extends GeneratedDatabase { ); } + Future markInboundGroupSessionsAsNeedingUpload(int client_id) { + return customUpdate( + 'UPDATE inbound_group_sessions SET uploaded = false WHERE client_id = :client_id', + variables: [Variable.withInt(client_id)], + updates: {inboundGroupSessions}, + updateKind: UpdateKind.update, + ); + } + Future storeUserDeviceKeysInfo( int client_id, String user_id, bool outdated) { return customInsert( diff --git a/lib/src/database/database.moor b/lib/src/database/database.moor index 63995746..32d9eb01 100644 --- a/lib/src/database/database.moor +++ b/lib/src/database/database.moor @@ -193,6 +193,7 @@ storeInboundGroupSession: INSERT OR REPLACE INTO inbound_group_sessions (client_ updateInboundGroupSessionIndexes: UPDATE inbound_group_sessions SET indexes = :indexes WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id; getInboundGroupSessionsToUpload: SELECT * FROM inbound_group_sessions WHERE uploaded = false LIMIT 500; markInboundGroupSessionAsUploaded: UPDATE inbound_group_sessions SET uploaded = true WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id; +markInboundGroupSessionsAsNeedingUpload: UPDATE inbound_group_sessions SET uploaded = false WHERE client_id = :client_id; storeUserDeviceKeysInfo: INSERT OR REPLACE INTO user_device_keys (client_id, user_id, outdated) VALUES (:client_id, :user_id, :outdated); setVerifiedUserDeviceKey: UPDATE user_device_keys_key SET verified = :verified WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id; setBlockedUserDeviceKey: UPDATE user_device_keys_key SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id; diff --git a/lib/src/utils/uia_request.dart b/lib/src/utils/uia_request.dart new file mode 100644 index 00000000..82fb3975 --- /dev/null +++ b/lib/src/utils/uia_request.dart @@ -0,0 +1,100 @@ +/* + * 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 '../../famedlysdk.dart'; + +class UiaRequest { + void Function() onUpdate; + void Function() onDone; + final Future Function(Map auth) request; + String session; + bool done = false; + bool fail = false; + T result; + Exception error; + Set nextStages = {}; + Map params = {}; + UiaRequest({this.onUpdate, this.request, this.onDone}) { + run(); + } + + Future run([Map auth]) async { + try { + auth ??= {}; + if (session != null) { + auth['session'] = session; + } + final res = await request(auth); + done = true; + result = res; + return res; + } on MatrixException catch (err) { + if (!(err.session is String)) { + rethrow; + } + session ??= err.session; + final completed = err.completedAuthenticationFlows ?? []; + final flows = err.authenticationFlows ?? []; + params = err.authenticationParams ?? {}; + nextStages = getNextStages(flows, completed); + if (nextStages.isEmpty) { + rethrow; + } + return null; + } catch (err) { + error = err is Exception ? err : Exception(err); + fail = true; + return null; + } finally { + if (onUpdate != null) { + onUpdate(); + } + if ((fail || done) && onDone != null) { + onDone(); + } + } + } + + Future completeStage(String type, [Map auth]) async { + auth ??= {}; + auth['type'] = type; + return await run(auth); + } + + Set getNextStages( + List flows, List completed) { + final nextStages = {}; + for (final flow in flows) { + final stages = flow.stages; + final nextStage = stages[completed.length]; + if (nextStage != null) { + var stagesValid = true; + for (var i = 0; i < completed.length; i++) { + if (stages[i] != completed[i]) { + stagesValid = false; + break; + } + } + if (stagesValid) { + nextStages.add(nextStage); + } + } + } + return nextStages; + } +} diff --git a/test/device_keys_list_test.dart b/test/device_keys_list_test.dart index 130a0ff6..f00efa8b 100644 --- a/test/device_keys_list_test.dart +++ b/test/device_keys_list_test.dart @@ -97,7 +97,7 @@ void main() { 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.unlock(recoveryKey: SSSS_KEY); await handle.maybeCacheAll(); expect(key.verified, true); diff --git a/test/encryption/bootstrap_test.dart b/test/encryption/bootstrap_test.dart new file mode 100644 index 00000000..e51c481c --- /dev/null +++ b/test/encryption/bootstrap_test.dart @@ -0,0 +1,266 @@ +/* + * 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:async'; +import 'dart:convert'; + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/encryption.dart'; +import 'package:famedlysdk/matrix_api/utils/logs.dart'; +import 'package:test/test.dart'; +import 'package:olm/olm.dart' as olm; + +import '../fake_client.dart'; + +void main() { + group('Bootstrap', () { + var olmEnabled = true; + try { + olm.init(); + olm.Account(); + } catch (_) { + olmEnabled = false; + Logs.warning('[LibOlm] Failed to load LibOlm: ' + _.toString()); + } + Logs.success('[LibOlm] Enabled: $olmEnabled'); + + if (!olmEnabled) return; + + Client client; + Map oldSecret; + String origKeyId; + + test('setupClient', () async { + client = await getClient(); + }); + + test('setup', () async { + Bootstrap bootstrap; + bootstrap = client.encryption.bootstrap( + onUpdate: () async { + while (bootstrap == null) { + await Future.delayed(Duration(milliseconds: 5)); + } + if (bootstrap.state == BootstrapState.askWipeSsss) { + bootstrap.wipeSsss(true); + } else if (bootstrap.state == BootstrapState.askNewSsss) { + await bootstrap.newSsss('foxies'); + } else if (bootstrap.state == BootstrapState.askWipeCrossSigning) { + bootstrap.wipeCrossSigning(true); + } else if (bootstrap.state == BootstrapState.askSetupCrossSigning) { + await bootstrap.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ); + } else if (bootstrap.state == BootstrapState.askWipeOnlineKeyBackup) { + bootstrap.wipeOnlineKeyBackup(true); + } else if (bootstrap.state == + BootstrapState.askSetupOnlineKeyBackup) { + await bootstrap.askSetupOnlineKeyBackup(true); + } + }, + ); + while (bootstrap.state != BootstrapState.done) { + await Future.delayed(Duration(milliseconds: 50)); + } + final defaultKey = client.encryption.ssss.open(); + await defaultKey.unlock(passphrase: 'foxies'); + + // test all the x-signing keys match up + for (final keyType in {'master', 'user_signing', 'self_signing'}) { + final privateKey = base64 + .decode(await defaultKey.getStored('m.cross_signing.$keyType')); + final keyObj = olm.PkSigning(); + try { + final pubKey = keyObj.init_with_seed(privateKey); + expect( + pubKey, + client.userDeviceKeys[client.userID] + .getCrossSigningKey(keyType) + .publicKey); + } finally { + keyObj.free(); + } + } + + await defaultKey.store('foxes', 'floof'); + await Future.delayed(Duration(milliseconds: 50)); + oldSecret = json.decode(json.encode(client.accountData['foxes'].content)); + origKeyId = defaultKey.keyId; + }, timeout: Timeout(Duration(minutes: 2))); + + test('change recovery passphrase', () async { + Bootstrap bootstrap; + bootstrap = client.encryption.bootstrap( + onUpdate: () async { + while (bootstrap == null) { + await Future.delayed(Duration(milliseconds: 5)); + } + if (bootstrap.state == BootstrapState.askWipeSsss) { + bootstrap.wipeSsss(false); + } else if (bootstrap.state == BootstrapState.askUseExistingSsss) { + bootstrap.useExistingSsss(false); + } else if (bootstrap.state == BootstrapState.askUnlockSsss) { + await bootstrap.oldSsssKeys[client.encryption.ssss.defaultKeyId] + .unlock(passphrase: 'foxies'); + bootstrap.unlockedSsss(); + } else if (bootstrap.state == BootstrapState.askNewSsss) { + await bootstrap.newSsss('newfoxies'); + } else if (bootstrap.state == BootstrapState.askWipeCrossSigning) { + bootstrap.wipeCrossSigning(false); + } else if (bootstrap.state == BootstrapState.askWipeOnlineKeyBackup) { + bootstrap.wipeOnlineKeyBackup(false); + } + }, + ); + while (bootstrap.state != BootstrapState.done) { + await Future.delayed(Duration(milliseconds: 50)); + } + final defaultKey = client.encryption.ssss.open(); + await defaultKey.unlock(passphrase: 'newfoxies'); + + // test all the x-signing keys match up + for (final keyType in {'master', 'user_signing', 'self_signing'}) { + final privateKey = base64 + .decode(await defaultKey.getStored('m.cross_signing.$keyType')); + final keyObj = olm.PkSigning(); + try { + final pubKey = keyObj.init_with_seed(privateKey); + expect( + pubKey, + client.userDeviceKeys[client.userID] + .getCrossSigningKey(keyType) + .publicKey); + } finally { + keyObj.free(); + } + } + + expect(await defaultKey.getStored('foxes'), 'floof'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('change passphrase with multiple keys', () async { + await client.setAccountData(client.userID, 'foxes', oldSecret); + await Future.delayed(Duration(milliseconds: 50)); + + Bootstrap bootstrap; + bootstrap = client.encryption.bootstrap( + onUpdate: () async { + while (bootstrap == null) { + await Future.delayed(Duration(milliseconds: 5)); + } + if (bootstrap.state == BootstrapState.askWipeSsss) { + bootstrap.wipeSsss(false); + } else if (bootstrap.state == BootstrapState.askUseExistingSsss) { + bootstrap.useExistingSsss(false); + } else if (bootstrap.state == BootstrapState.askUnlockSsss) { + await bootstrap.oldSsssKeys[client.encryption.ssss.defaultKeyId] + .unlock(passphrase: 'newfoxies'); + await bootstrap.oldSsssKeys[origKeyId].unlock(passphrase: 'foxies'); + bootstrap.unlockedSsss(); + } else if (bootstrap.state == BootstrapState.askNewSsss) { + await bootstrap.newSsss('supernewfoxies'); + } else if (bootstrap.state == BootstrapState.askWipeCrossSigning) { + bootstrap.wipeCrossSigning(false); + } else if (bootstrap.state == BootstrapState.askWipeOnlineKeyBackup) { + bootstrap.wipeOnlineKeyBackup(false); + } + }, + ); + while (bootstrap.state != BootstrapState.done) { + await Future.delayed(Duration(milliseconds: 50)); + } + final defaultKey = client.encryption.ssss.open(); + await defaultKey.unlock(passphrase: 'supernewfoxies'); + + // test all the x-signing keys match up + for (final keyType in {'master', 'user_signing', 'self_signing'}) { + final privateKey = base64 + .decode(await defaultKey.getStored('m.cross_signing.$keyType')); + final keyObj = olm.PkSigning(); + try { + final pubKey = keyObj.init_with_seed(privateKey); + expect( + pubKey, + client.userDeviceKeys[client.userID] + .getCrossSigningKey(keyType) + .publicKey); + } finally { + keyObj.free(); + } + } + + expect(await defaultKey.getStored('foxes'), 'floof'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('setup new ssss', () async { + client.accountData.clear(); + Bootstrap bootstrap; + bootstrap = client.encryption.bootstrap( + onUpdate: () async { + while (bootstrap == null) { + await Future.delayed(Duration(milliseconds: 5)); + } + if (bootstrap.state == BootstrapState.askNewSsss) { + await bootstrap.newSsss('thenewestfoxies'); + } else if (bootstrap.state == BootstrapState.askSetupCrossSigning) { + await bootstrap.askSetupCrossSigning(); + } else if (bootstrap.state == + BootstrapState.askSetupOnlineKeyBackup) { + await bootstrap.askSetupOnlineKeyBackup(false); + } + }, + ); + while (bootstrap.state != BootstrapState.done) { + await Future.delayed(Duration(milliseconds: 50)); + } + final defaultKey = client.encryption.ssss.open(); + await defaultKey.unlock(passphrase: 'thenewestfoxies'); + }, timeout: Timeout(Duration(minutes: 2))); + + test('bad ssss', () async { + client.accountData.clear(); + await client.setAccountData(client.userID, 'foxes', oldSecret); + await Future.delayed(Duration(milliseconds: 50)); + var askedBadSsss = false; + Bootstrap bootstrap; + bootstrap = client.encryption.bootstrap( + onUpdate: () async { + while (bootstrap == null) { + await Future.delayed(Duration(milliseconds: 5)); + } + if (bootstrap.state == BootstrapState.askWipeSsss) { + bootstrap.wipeSsss(false); + } else if (bootstrap.state == BootstrapState.askBadSsss) { + askedBadSsss = true; + bootstrap.ignoreBadSecrets(false); + } + }, + ); + while (bootstrap.state != BootstrapState.error) { + await Future.delayed(Duration(milliseconds: 50)); + } + expect(askedBadSsss, true); + }); + + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/encryption/key_verification_test.dart b/test/encryption/key_verification_test.dart index fb11b052..604eb0c1 100644 --- a/test/encryption/key_verification_test.dart +++ b/test/encryption/key_verification_test.dart @@ -35,7 +35,7 @@ class MockSSSS extends SSSS { Future maybeRequestAll([List devices]) async { requestedSecrets = true; final handle = open(); - handle.unlock(recoveryKey: SSSS_KEY); + await handle.unlock(recoveryKey: SSSS_KEY); await handle.maybeCacheAll(); } } diff --git a/test/encryption/online_key_backup_test.dart b/test/encryption/online_key_backup_test.dart index ba9359a4..7d46191d 100644 --- a/test/encryption/online_key_backup_test.dart +++ b/test/encryption/online_key_backup_test.dart @@ -55,7 +55,7 @@ void main() { 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.unlock(recoveryKey: SSSS_KEY); await handle.maybeCacheAll(); expect(await client.encryption.keyManager.isCached(), true); }); diff --git a/test/encryption/ssss_test.dart b/test/encryption/ssss_test.dart index 88945c08..5b2589e9 100644 --- a/test/encryption/ssss_test.dart +++ b/test/encryption/ssss_test.dart @@ -38,7 +38,7 @@ class MockSSSS extends SSSS { Future maybeRequestAll([List devices]) async { requestedSecrets = true; final handle = open(); - handle.unlock(recoveryKey: SSSS_KEY); + await handle.unlock(recoveryKey: SSSS_KEY); await handle.maybeCacheAll(); } } @@ -80,7 +80,7 @@ void main() { final handle = client.encryption.ssss.open(); var failed = false; try { - handle.unlock(passphrase: 'invalid'); + await handle.unlock(passphrase: 'invalid'); } catch (_) { failed = true; } @@ -88,14 +88,14 @@ void main() { expect(handle.isUnlocked, false); failed = false; try { - handle.unlock(recoveryKey: 'invalid'); + await handle.unlock(recoveryKey: 'invalid'); } catch (_) { failed = true; } expect(failed, true); expect(handle.isUnlocked, false); - handle.unlock(passphrase: SSSS_PASSPHRASE); - handle.unlock(recoveryKey: SSSS_KEY); + await handle.unlock(passphrase: SSSS_PASSPHRASE); + await handle.unlock(recoveryKey: SSSS_KEY); expect(handle.isUnlocked, true); FakeMatrixApi.calledEndpoints.clear(); await handle.store('best animal', 'foxies'); @@ -114,32 +114,32 @@ void main() { test('cache', () async { final handle = - client.encryption.ssss.open('m.cross_signing.self_signing'); - handle.unlock(recoveryKey: SSSS_KEY); + client.encryption.ssss.open(EventTypes.CrossSigningSelfSigning); + await handle.unlock(recoveryKey: SSSS_KEY); expect( (await client.encryption.ssss - .getCached('m.cross_signing.self_signing')) != + .getCached(EventTypes.CrossSigningSelfSigning)) != null, false); expect( (await client.encryption.ssss - .getCached('m.cross_signing.user_signing')) != + .getCached(EventTypes.CrossSigningUserSigning)) != null, false); - await handle.getStored('m.cross_signing.self_signing'); + await handle.getStored(EventTypes.CrossSigningSelfSigning); expect( (await client.encryption.ssss - .getCached('m.cross_signing.self_signing')) != + .getCached(EventTypes.CrossSigningSelfSigning)) != null, true); await handle.maybeCacheAll(); expect( (await client.encryption.ssss - .getCached('m.cross_signing.user_signing')) != + .getCached(EventTypes.CrossSigningUserSigning)) != null, true); expect( - (await client.encryption.ssss.getCached('m.megolm_backup.v1')) != + (await client.encryption.ssss.getCached(EventTypes.MegolmBackup)) != null, true); }); @@ -163,7 +163,7 @@ void main() { content: { 'action': 'request', 'requesting_device_id': 'OTHERDEVICE', - 'name': 'm.cross_signing.self_signing', + 'name': EventTypes.CrossSigningSelfSigning, 'request_id': '1', }, ); @@ -183,7 +183,7 @@ void main() { content: { 'action': 'request', 'requesting_device_id': 'OTHERDEVICE', - 'name': 'm.cross_signing.self_signing', + 'name': EventTypes.CrossSigningSelfSigning, 'request_id': '1', }, ); @@ -219,7 +219,7 @@ void main() { content: { 'action': 'request_cancellation', 'requesting_device_id': 'OTHERDEVICE', - 'name': 'm.cross_signing.self_signing', + 'name': EventTypes.CrossSigningSelfSigning, 'request_id': '1', }, ); @@ -240,7 +240,7 @@ void main() { content: { 'action': 'request', 'requesting_device_id': 'OTHERDEVICE', - 'name': 'm.cross_signing.self_signing', + 'name': EventTypes.CrossSigningSelfSigning, 'request_id': '1', }, ); @@ -258,8 +258,8 @@ void main() { 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); + client.encryption.ssss.open(EventTypes.CrossSigningSelfSigning); + await handle.unlock(recoveryKey: SSSS_KEY); await client.encryption.ssss.clearCache(); client.encryption.ssss.pendingShareRequests.clear(); @@ -280,9 +280,9 @@ void main() { // test the different validators for (final type in [ - 'm.cross_signing.self_signing', - 'm.cross_signing.user_signing', - 'm.megolm_backup.v1' + EventTypes.CrossSigningSelfSigning, + EventTypes.CrossSigningUserSigning, + EventTypes.MegolmBackup ]) { final secret = await handle.getStored(type); await client.encryption.ssss.clearCache(); @@ -378,7 +378,7 @@ void main() { // validator doesn't check out await client.encryption.ssss.clearCache(); client.encryption.ssss.pendingShareRequests.clear(); - await client.encryption.ssss.request('m.megolm_backup.v1', [key]); + await client.encryption.ssss.request(EventTypes.MegolmBackup, [key]); event = ToDeviceEvent( sender: client.userID, type: 'm.secret.send', @@ -391,8 +391,8 @@ void main() { }, ); await client.encryption.ssss.handleToDeviceEvent(event); - expect( - await client.encryption.ssss.getCached('m.megolm_backup.v1'), null); + expect(await client.encryption.ssss.getCached(EventTypes.MegolmBackup), + null); }); test('request all', () async { @@ -417,6 +417,21 @@ void main() { expect((client.encryption.ssss as MockSSSS).requestedSecrets, false); }); + test('createKey', () async { + // with passphrase + var newKey = await client.encryption.ssss.createKey('test'); + expect(client.encryption.ssss.isKeyValid(newKey.keyId), true); + var testKey = await client.encryption.ssss.open(newKey.keyId); + await testKey.unlock(passphrase: 'test'); + await testKey.setPrivateKey(newKey.privateKey); + + // without passphrase + newKey = await client.encryption.ssss.createKey(); + expect(client.encryption.ssss.isKeyValid(newKey.keyId), true); + testKey = await client.encryption.ssss.open(newKey.keyId); + await testKey.setPrivateKey(newKey.privateKey); + }); + test('dispose client', () async { await client.dispose(closeDatabase: true); }); diff --git a/test/fake_client.dart b/test/fake_client.dart index 8859adf8..2bc3b903 100644 --- a/test/fake_client.dart +++ b/test/fake_client.dart @@ -29,8 +29,12 @@ const pickledOlmAccount = 'N2v1MkIFGcl0mQpo2OCwSopxPQJ0wnl7oe7PKiT4141AijfdTIhRu+ceXzXKy3Kr00nLqXtRv7kid6hU4a+V0rfJWLL0Y51+3Rp/ORDVnQy+SSeo6Fn4FHcXrxifJEJ0djla5u98fBcJ8BSkhIDmtXRPi5/oJAvpiYn+8zMjFHobOeZUAxYR0VfQ9JzSYBsSovoQ7uFkNks1M4EDUvHtuyg3RxViwdNxs3718fyAqQ/VSwbXsY0Nl+qQbF+nlVGHenGqk5SuNl1P6e1PzZxcR0IfXA94Xij1Ob5gDv5YH4UCn9wRMG0abZsQP0YzpDM0FLaHSCyo9i5JD/vMlhH+nZWrgAzPPCTNGYewNV8/h3c+VyJh8ZTx/fVi6Yq46Fv+27Ga2ETRZ3Qn+Oyx6dLBjnBZ9iUvIhqpe2XqaGA1PopOz8iDnaZitw'; Future getClient() async { - final client = Client('testclient', - httpClient: FakeMatrixApi(), databaseBuilder: getDatabase); + final client = Client( + 'testclient', + httpClient: FakeMatrixApi(), + databaseBuilder: getDatabase, + ); + FakeMatrixApi.client = client; await client.checkHomeserver('https://fakeServer.notExisting'); client.init( newToken: 'abcd', diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 795bf9f0..8cad810e 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'package:famedlysdk/famedlysdk.dart' as sdk; + import 'dart:convert'; import 'dart:core'; import 'dart:math'; @@ -27,6 +29,7 @@ import 'package:http/testing.dart'; class FakeMatrixApi extends MockClient { static final calledEndpoints = >{}; static int eventCounter = 0; + static sdk.Client client; FakeMatrixApi() : super((request) async { @@ -87,6 +90,25 @@ class FakeMatrixApi extends MockClient { res = { 'next_batch': DateTime.now().millisecondsSinceEpoch.toString }; + } else if (method == 'PUT' && + client != null && + action.contains('/account_data/') && + !action.contains('/room/')) { + final type = Uri.decodeComponent(action.split('/').last); + final syncUpdate = sdk.SyncUpdate() + ..accountData = [ + sdk.BasicEvent() + ..content = json.decode(data) + ..type = type + ]; + if (client.database != null) { + await client.database.transaction(() async { + await client.handleSync(syncUpdate); + }); + } else { + await client.handleSync(syncUpdate); + } + res = {}; } else { res = { 'errcode': 'M_UNRECOGNIZED', @@ -538,7 +560,7 @@ class FakeMatrixApi extends MockClient { 'type': 'm.direct' }, { - 'type': 'm.secret_storage.default_key', + 'type': EventTypes.SecretStorageDefaultKey, 'content': {'key': '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3'} }, { @@ -546,7 +568,7 @@ class FakeMatrixApi extends MockClient { 'content': { 'algorithm': AlgorithmTypes.secretStorageV1AesHmcSha2, 'passphrase': { - 'algorithm': 'm.pbkdf2', + 'algorithm': AlgorithmTypes.pbkdf2, 'iterations': 500000, 'salt': 'F4jJ80mr0Fc8mRwU9JgA3lQDyjPuZXQL' }, @@ -568,7 +590,7 @@ class FakeMatrixApi extends MockClient { } }, { - 'type': 'm.cross_signing.self_signing', + 'type': EventTypes.CrossSigningSelfSigning, 'content': { 'encrypted': { '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { @@ -581,7 +603,7 @@ class FakeMatrixApi extends MockClient { } }, { - 'type': 'm.cross_signing.user_signing', + 'type': EventTypes.CrossSigningUserSigning, 'content': { 'encrypted': { '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { @@ -594,7 +616,7 @@ class FakeMatrixApi extends MockClient { } }, { - 'type': 'm.megolm_backup.v1', + 'type': EventTypes.MegolmBackup, 'content': { 'encrypted': { '0FajDWYaM6wQ4O60OZnLvwZfsBNu4Bu3': { @@ -1978,7 +2000,28 @@ 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/unstable/keys/device_signing/upload': (var reqI) { + if (client != null) { + final jsonBody = json.decode(reqI); + for (final keyType in { + 'master_key', + 'self_signing_key', + 'user_signing_key' + }) { + if (jsonBody[keyType] != null) { + final key = + sdk.CrossSigningKey.fromJson(jsonBody[keyType], client); + client.userDeviceKeys[client.userID].crossSigningKeys + .removeWhere((k, v) => v.usage.contains(key.usage.first)); + client.userDeviceKeys[client.userID] + .crossSigningKeys[key.publicKey] = key; + } + } + // and generate a fake sync + client.handleSync(sdk.SyncUpdate()); + } + return {}; + }, '/client/r0/keys/signatures/upload': (var reqI) => {'failures': {}}, '/client/unstable/room_keys/version': (var reqI) => {'version': '5'}, }, diff --git a/test/uia_test.dart b/test/uia_test.dart new file mode 100644 index 00000000..59f7bc72 --- /dev/null +++ b/test/uia_test.dart @@ -0,0 +1,94 @@ +/* + * 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:async'; + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:test/test.dart'; + +void main() { + group('UIA', () { + test('it should work', () async { + var completed = []; + var updated = 0; + var finished = false; + final request = UiaRequest( + request: (auth) async { + if (auth['session'] != null && auth['session'] != 'foxies') { + throw MatrixException.fromJson({}); + } + if (auth['type'] == 'stage1') { + if (completed.isEmpty) { + completed.add('stage1'); + } + } else if (auth['type'] == 'stage2') { + if (completed.length == 1 && completed[0] == 'stage1') { + // okay, we are done! + return 'FOXIES ARE FLOOOOOFY!!!!!'; + } + } + final res = { + 'session': 'foxies', + 'completed': completed, + 'flows': [ + { + 'stages': ['stage1', 'stage2'], + } + ], + 'params': {}, + }; + throw MatrixException.fromJson(res); + }, + onUpdate: () => updated++, + onDone: () => finished = true, + ); + await Future.delayed(Duration(milliseconds: 50)); + expect(request.nextStages.contains('stage1'), true); + expect(request.nextStages.length, 1); + expect(updated, 1); + expect(finished, false); + await request.completeStage('stage1'); + expect(request.nextStages.contains('stage2'), true); + expect(request.nextStages.length, 1); + expect(updated, 2); + expect(finished, false); + final res = await request.completeStage('stage2'); + expect(res, 'FOXIES ARE FLOOOOOFY!!!!!'); + expect(request.result, 'FOXIES ARE FLOOOOOFY!!!!!'); + expect(request.done, true); + expect(updated, 3); + expect(finished, true); + }); + test('it should throw errors', () async { + var updated = false; + var finished = false; + final request = UiaRequest( + request: (auth) async { + throw 'nope'; + }, + onUpdate: () => updated = true, + onDone: () => finished = true, + ); + await Future.delayed(Duration(milliseconds: 50)); + expect(request.fail, true); + expect(updated, true); + expect(finished, true); + expect(request.error.toString(), Exception('nope').toString()); + }); + }); +}