diff --git a/lib/src/client.dart b/lib/src/client.dart index 8f39895..5c75423 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -47,6 +47,7 @@ import 'package:pedantic/pedantic.dart'; import 'event.dart'; import 'room.dart'; +import 'ssss.dart'; import 'sync/event_update.dart'; import 'sync/room_update.dart'; import 'sync/user_update.dart'; @@ -79,6 +80,8 @@ class Client { bool enableE2eeRecovery; + SSSS ssss; + /// Create a client /// clientName = unique identifier of this client /// debug: Print debug output? @@ -86,6 +89,7 @@ class Client { /// enableE2eeRecovery: Enable additional logic to try to recover from bad e2ee sessions Client(this.clientName, {this.debug = false, this.database, this.enableE2eeRecovery = false}) { + ssss = SSSS(this); onLoginStateChanged.stream.listen((loginState) { print('LoginState: ${loginState.toString()}'); }); @@ -1146,6 +1150,9 @@ class Client { if (toDeviceEvent.type.startsWith('m.key.verification.')) { _handleToDeviceKeyVerificationRequest(toDeviceEvent); } + if (toDeviceEvent.type.startsWith('m.secret.')) { + ssss.handleToDeviceEvent(toDeviceEvent); + } onToDeviceEvent.add(toDeviceEvent); } } @@ -2013,12 +2020,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 f83b15f..7a673d1 100644 --- a/lib/src/database/database.dart +++ b/lib/src/database/database.dart @@ -46,6 +46,7 @@ class Database extends _$Database { 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'); @@ -125,6 +126,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) @@ -416,10 +425,14 @@ class Database extends _$Database { ..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 daea006..437265c 100644 --- a/lib/src/database/database.g.dart +++ b/lib/src/database/database.g.dart @@ -4801,6 +4801,263 @@ 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 content; + DbSSSSCache( + {@required this.clientId, + @required this.type, + @required this.keyId, + @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']), + 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 || 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']), + 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), + 'content': serializer.toJson(content), + }; + } + + DbSSSSCache copyWith( + {int clientId, String type, String keyId, String content}) => + DbSSSSCache( + clientId: clientId ?? this.clientId, + type: type ?? this.type, + keyId: keyId ?? this.keyId, + content: content ?? this.content, + ); + @override + String toString() { + return (StringBuffer('DbSSSSCache(') + ..write('clientId: $clientId, ') + ..write('type: $type, ') + ..write('keyId: $keyId, ') + ..write('content: $content') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc(clientId.hashCode, + $mrjc(type.hashCode, $mrjc(keyId.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.content == this.content); +} + +class SsssCacheCompanion extends UpdateCompanion { + final Value clientId; + final Value type; + final Value keyId; + final Value content; + const SsssCacheCompanion({ + this.clientId = const Value.absent(), + this.type = const Value.absent(), + this.keyId = const Value.absent(), + this.content = const Value.absent(), + }); + SsssCacheCompanion.insert({ + @required int clientId, + @required String type, + @required String keyId, + @required String content, + }) : clientId = Value(clientId), + type = Value(type), + keyId = Value(keyId), + content = Value(content); + static Insertable custom({ + Expression clientId, + Expression type, + Expression keyId, + Expression content, + }) { + return RawValuesInsertable({ + if (clientId != null) 'client_id': clientId, + if (type != null) 'type': type, + if (keyId != null) 'key_id': keyId, + if (content != null) 'content': content, + }); + } + + SsssCacheCompanion copyWith( + {Value clientId, + Value type, + Value keyId, + Value content}) { + return SsssCacheCompanion( + clientId: clientId ?? this.clientId, + type: type ?? this.type, + keyId: keyId ?? this.keyId, + 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 (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 _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, 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('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; @@ -5087,6 +5344,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) { @@ -5498,6 +5757,36 @@ abstract class _$Database extends GeneratedDatabase { ); } + Future storeSSSSCache( + int client_id, String type, String key_id, String content) { + return customInsert( + 'INSERT OR REPLACE INTO ssss_cache (client_id, type, key_id, content) VALUES (:client_id, :type, :key_id, :content)', + variables: [ + Variable.withInt(client_id), + Variable.withString(type), + Variable.withString(key_id), + 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'), + 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 insertClient( String name, String homeserver_url, @@ -5924,6 +6213,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 049c792..5c84841 100644 --- a/lib/src/database/database.moor +++ b/lib/src/database/database.moor @@ -74,6 +74,14 @@ 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, + 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, @@ -186,6 +194,8 @@ setVerifiedUserCrossSigningKey: UPDATE user_cross_signing_keys SET verified = :v 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, content) VALUES (:client_id, :type, :key_id, :content); +dbGetSSSSCache: SELECT * FROM ssss_cache WHERE client_id = :client_id AND type = :type; 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/ssss.dart b/lib/src/ssss.dart new file mode 100644 index 0000000..62f2348 --- /dev/null +++ b/lib/src/ssss.dart @@ -0,0 +1,456 @@ +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:random_string/random_string.dart'; + +import 'client.dart'; +import 'account_data.dart'; +import 'utils/device_keys_list.dart'; +import 'utils/to_device_event.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 = const Base58Codec(BASE58_ALPHABET); +const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; +const OLM_PRIVATE_KEY_LENGTH = 32; // TODO: fetch from dart-olm +const AES_BLOCKSIZE = 16; + +class SSSS { + final Client client; + final pendingShareRequests = {}; + SSSS(this.client); + + 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 a = aesKey.bytes + utf8.encode(name) + b; + 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); + + // workaround for https://github.com/leocavalcante/encrypt/issues/136 + var plain = Uint8List.fromList(utf8.encode(data)); + final bytesMissing = AES_BLOCKSIZE - (plain.lengthInBytes % AES_BLOCKSIZE); + if (bytesMissing != AES_BLOCKSIZE) { + // we want to be able to modify it + final oldPlain = plain; + plain = Uint8List(plain.lengthInBytes + bytesMissing); + for (var i = 0; i < oldPlain.lengthInBytes; i++) { + plain[i] = oldPlain[i]; + } + } + var ciphertext = AES(Key(keys.aesKey), mode: AESMode.ctr, padding: null) + .encrypt(plain, iv: IV(iv)).bytes; + if (bytesMissing != AES_BLOCKSIZE) { + // chop off those extra bytes again + ciphertext = ciphertext.sublist(0, plain.length - bytesMissing); + } + + 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 hmac = base64 + .encode(Hmac(sha256, keys.hmacKey) + .convert(base64.decode(data.ciphertext)) + .bytes) + .replaceAll(RegExp(r'=+$'), ''); + if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) { + throw 'Bad MAC'; + } + // workaround for https://github.com/leocavalcante/encrypt/issues/136 + var cipher = base64.decode(data.ciphertext); + final bytesMissing = AES_BLOCKSIZE - (cipher.lengthInBytes % AES_BLOCKSIZE); + if (bytesMissing != AES_BLOCKSIZE) { + // we want to be able to modify it + final oldCipher = cipher; + cipher = Uint8List(cipher.lengthInBytes + bytesMissing); + for (var i = 0; i < oldCipher.lengthInBytes; i++) { + cipher[i] = oldCipher[i]; + } + } + final decipher = AES(Key(keys.aesKey), mode: AESMode.ctr, padding: null).decrypt( + Encrypted(cipher), + iv: IV(base64.decode(data.iv))); + if (bytesMissing != AES_BLOCKSIZE) { + // chop off those extra bytes again + return String.fromCharCodes(decipher.sublist(0, decipher.length - bytesMissing)); + } + 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 keyFromPassword(String password, _PasswordInfo info) { + if (info.algorithm != 'm.pbkdf2') { + throw 'Unknown algorithm'; + } + final generator = PBKDF2(hashAlgorithm: sha512); + return Uint8List.fromList(generator.generateKey( + password, info.salt, info.iterations, info.bits != null ? info.bits / 8 : 32)); + } + + 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']; + } + + AccountData getKey(String keyId) { + return client.accountData['m.secret_storage.key.${keyId}']; + } + + bool checkKey(Uint8List key, AccountData 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)) { + 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, 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.jsonRequest( + type: HTTPType.PUT, + action: '/client/r0/user/${client.userID}/account_data/${type}', + data: content, + ); + if (CACHE_TYPES.contains(type) && client.database != null) { + // cache the thing + await client.database.storeSSSSCache(client.id, type, keyId, secret); + } + } + + 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 = + randomString(512) + DateTime.now().millisecondsSinceEpoch.toString(); + 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'])) { + 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 we know that it must have originated from a trusted source + pendingShareRequests.remove(request.requestId); + if (!(event.content['secret'] is String)) { + print('[SSSS] Secret wasn\'t a string'); + return; // the secret wasn't a string....wut? + } + 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) { + await client.database.storeSSSSCache( + client.id, request.type, keyId, event.content['secret']); + } + } + } + } + + Set keyIdsFromType(String type) { + final data = client.accountData[type]; + if (data == null) { + return null; + } + if (data.content['encrypted'] is Map) { + final keys = Set(); + String maybeKey; + 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]) { + if (identifier == null) { + 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 _PasswordInfo { + final String algorithm; + final String salt; + final int iterations; + final int bits; + + _PasswordInfo({this.algorithm, this.salt, this.iterations, this.bits}); +} + +class OpenSSSS { + final SSSS ssss; + final String keyId; + final AccountData keyData; + OpenSSSS({this.ssss, this.keyId, this.keyData}); + Uint8List privateKey; + + bool get isUnlocked => privateKey != null; + + void unlock({String password, String recoveryKey}) { + if (password != null) { + privateKey = SSSS.keyFromPassword( + password, + _PasswordInfo( + 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); + } +} diff --git a/lib/src/utils/key_verification.dart b/lib/src/utils/key_verification.dart index 614c970..9f30d82 100644 --- a/lib/src/utils/key_verification.dart +++ b/lib/src/utils/key_verification.dart @@ -132,7 +132,8 @@ class KeyVerification { Future start() async { if (room == null) { - transactionId = randomString(512); + transactionId = + randomString(512) + DateTime.now().millisecondsSinceEpoch.toString(); } await send('m.key.verification.request', { 'methods': VERIFICATION_METHODS, @@ -323,16 +324,31 @@ class KeyVerification { } } // okay, we reached this far, so all the devices are verified! + var verifiedMasterKey = false; + final verifiedUserDevices = []; for (final verifyDeviceId in verifiedDevices) { if (client.userDeviceKeys[userId].deviceKeys .containsKey(verifyDeviceId)) { - await client.userDeviceKeys[userId].deviceKeys[verifyDeviceId] - .setVerified(true); + final key = client.userDeviceKeys[userId].deviceKeys[verifyDeviceId]; + await key.setVerified(true); + verifiedUserDevices.add(key); } else if (client.userDeviceKeys[userId].crossSigningKeys .containsKey(verifyDeviceId)) { - await client.userDeviceKeys[userId].crossSigningKeys[verifyDeviceId] - .setVerified(true); - // TODO: sign the other persons master key + final key = + client.userDeviceKeys[userId].crossSigningKeys[verifyDeviceId]; + await key.setVerified(true); + if (key.usage.contains('master')) { + verifiedMasterKey = true; + } + } + } + if (verifiedMasterKey) { + if (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 + client.ssss.maybeRequestAll(verifiedUserDevices); + } else { + // it was someone elses master key, let's sign it } } } diff --git a/pubspec.lock b/pubspec.lock index 7f54f84..509c244 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.1" ffi: 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..d5a29ba 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.1 + 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