diff --git a/doc/end-to-end-encryption.md b/doc/end-to-end-encryption.md index 4a7594cdf..577215b7c 100644 --- a/doc/end-to-end-encryption.md +++ b/doc/end-to-end-encryption.md @@ -82,4 +82,26 @@ identity and get a new key with `client.initCryptoIdentity()` at any time. > **key verification** to connect with another session which is already connected. > > The Client would then request all necessary secrets of your crypto identity -> automatically via **to-device-messaging**. \ No newline at end of file +> automatically via **to-device-messaging**. + +### Trust On First Use (Tofu) + +With **Trust On First Use** you can inform the user when the crypto identity of +a participant changes. This is usually checked when preparing the encryption +before sending a message into a room. Therefore a Tofu Event is +connected to a room but sent only once per user. + +To enable Tofu, just implement the `onTofuEvent` callback in the client +constructor: + +```dart +Client('Client Name', + // ... + onTofuEvent: (room, userIds) { + print('$userIds have changed their crypto identity!'); + } +); +``` + +By default it sends a state event of type `sdk.matrix.dart.tofu_notification` +into the room by using the function `sendTofuEvent()`. \ No newline at end of file diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index 052764a8a..004414c11 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -323,6 +323,50 @@ class KeyManager { return true; } + // next check if the devices in the room changed + final devicesToReceive = []; + final newDeviceKeys = await room.getUserDeviceKeys(); + final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys); + // first check for user differences + final oldUserIds = sess.devices.keys.toSet(); + final newUserIds = newDeviceKeyIds.keys.toSet(); + + // Update TOFU states: + final onTofuEvent = client.onTofuEvent; + if (onTofuEvent != null) { + final nonTofuMasterKeys = newUserIds + .where((userId) => userId != client.userID) + .map((userId) => client.userDeviceKeys[userId]?.masterKey) + .whereType() + .where((key) => !key.tofuVerified && !key.verified); + if (nonTofuMasterKeys.isNotEmpty) { + // Inform about changed keys: + final userIdsWithChangedMasterKeys = nonTofuMasterKeys + .where((key) => key.lastSeenPublicKey != null) + .map((key) => key.userId) + .toSet(); + if (userIdsWithChangedMasterKeys.isNotEmpty) { + onTofuEvent(room, userIdsWithChangedMasterKeys); + } + + // Update last seen public key for each master key: + for (final masterKey in nonTofuMasterKeys) { + if (masterKey.lastSeenPublicKey == null) { + Logs().d( + 'Trust On First Use for ${masterKey.userId} master key', + masterKey.publicKey, + ); + } else { + Logs().d( + '${masterKey.userId} has a new master key', + masterKey.publicKey, + ); + } + await masterKey.updateLastSeenPublicKey(); + } + } + } + if (!wipe) { // first check if it needs to be rotated final encryptionContent = @@ -349,13 +393,6 @@ class KeyManager { } if (!wipe) { - // next check if the devices in the room changed - final devicesToReceive = []; - final newDeviceKeys = await room.getUserDeviceKeys(); - final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys); - // first check for user differences - final oldUserIds = sess.devices.keys.toSet(); - final newUserIds = newDeviceKeyIds.keys.toSet(); if (oldUserIds.difference(newUserIds).isNotEmpty) { // a user left the room, we must wipe the session wipe = true; diff --git a/lib/matrix_api_lite/model/event_types.dart b/lib/matrix_api_lite/model/event_types.dart index cd4957327..54c1d4f4e 100644 --- a/lib/matrix_api_lite/model/event_types.dart +++ b/lib/matrix_api_lite/model/event_types.dart @@ -119,4 +119,7 @@ abstract class EventTypes { static const String GroupCallMemberAssertedIdentity = '$GroupCallMember.asserted_identity'; static const GroupCallMemberReaction = 'com.famedly.call.member.reaction'; + + // Internal + static const String TofuNotification = 'sdk.matrix.dart.tofu_notification'; } diff --git a/lib/src/client.dart b/lib/src/client.dart index 912cafc89..61b913a7d 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -36,6 +36,7 @@ import 'package:matrix/src/models/timeline_chunk.dart'; import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:matrix/src/utils/client_init_exception.dart'; import 'package:matrix/src/utils/multilock.dart'; +import 'package:matrix/src/utils/on_tofu_event.dart'; import 'package:matrix/src/utils/request_and_cache.dart'; import 'package:matrix/src/utils/run_benchmarked.dart'; import 'package:matrix/src/utils/run_in_root.dart'; @@ -199,6 +200,7 @@ class Client extends MatrixApi { this.customImageResizer, this.shareKeysWith = ShareKeysWith.crossVerifiedIfEnabled, this.enableDehydratedDevices = false, + this.onTofuEvent = sendTofuEvent, this.receiptsPublicByDefault = true, /// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout @@ -382,6 +384,8 @@ class Client extends MatrixApi { bool enableDehydratedDevices = false; + void Function(Room room, Set userIds)? onTofuEvent; + final String dehydratedDeviceDisplayName; /// Whether read receipts are sent as public receipts by default or just as private receipts. @@ -3536,11 +3540,18 @@ class Client extends MatrixApi { final oldKeys = Map.from(userKeys.crossSigningKeys); userKeys.crossSigningKeys = {}; + + final entry = CrossSigningKey.fromMatrixCrossSigningKey( + crossSigningKeyListEntry.value, + this, + ); + // add the types we aren't handling atm back for (final oldEntry in oldKeys.entries) { if (!oldEntry.value.usage.contains(keyType)) { userKeys.crossSigningKeys[oldEntry.key] = oldEntry.value; } else { + entry.lastSeenPublicKey = oldEntry.value.lastSeenPublicKey; // There is a previous cross-signing key with this usage, that we no // longer need/use. Clear it from the database. dbActions.add( @@ -3549,10 +3560,6 @@ class Client extends MatrixApi { ); } } - final entry = CrossSigningKey.fromMatrixCrossSigningKey( - crossSigningKeyListEntry.value, - this, - ); final publicKey = entry.publicKey; if (entry.isValid && publicKey != null) { final oldKey = oldKeys[publicKey]; @@ -3577,6 +3584,7 @@ class Client extends MatrixApi { json.encode(entry.toJson()), entry.directVerified, entry.blocked, + entry.lastSeenPublicKey, ), ); } @@ -4135,6 +4143,7 @@ class Client extends MatrixApi { jsonEncode(crossSigningKey.toJson()), crossSigningKey.directVerified, crossSigningKey.blocked, + crossSigningKey.lastSeenPublicKey, ); } } diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 208bd351f..530fd3148 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -265,6 +265,7 @@ abstract class DatabaseApi { String content, bool verified, bool blocked, + String? lastSeenPublicKey, ); Future deleteFromToDeviceQueue(int id); @@ -280,8 +281,9 @@ abstract class DatabaseApi { Future setVerifiedUserCrossSigningKey( bool verified, String userId, - String publicKey, - ); + String publicKey, { + String? lastSeenPublicKey, + }); Future setBlockedUserCrossSigningKey( bool blocked, diff --git a/lib/src/database/matrix_sdk_database.dart b/lib/src/database/matrix_sdk_database.dart index 5c1d4d7eb..d5279eacd 100644 --- a/lib/src/database/matrix_sdk_database.dart +++ b/lib/src/database/matrix_sdk_database.dart @@ -1088,14 +1088,18 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { Future setVerifiedUserCrossSigningKey( bool verified, String userId, - String publicKey, - ) async { + String publicKey, { + String? lastSeenPublicKey, + }) async { final raw = copyMap( (await _userCrossSigningKeysBox .get(TupleKey(userId, publicKey).toString())) ?? {}, ); raw['verified'] = verified; + if (lastSeenPublicKey != null) { + raw['last_seen_public_key'] = lastSeenPublicKey; + } await _userCrossSigningKeysBox.put( TupleKey(userId, publicKey).toString(), raw, @@ -1435,6 +1439,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { String content, bool verified, bool blocked, + String? lastSeenPublicKey, ) async { await _userCrossSigningKeysBox.put( TupleKey(userId, publicKey).toString(), @@ -1444,6 +1449,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage { 'content': content, 'verified': verified, 'blocked': blocked, + if (lastSeenPublicKey != null) + 'last_seen_public_key': lastSeenPublicKey, }, ); } diff --git a/lib/src/utils/device_keys_list.dart b/lib/src/utils/device_keys_list.dart index f6d09b607..a052f2403 100644 --- a/lib/src/utils/device_keys_list.dart +++ b/lib/src/utils/device_keys_list.dart @@ -406,6 +406,13 @@ class CrossSigningKey extends SignableKey { String? get publicKey => identifier; late List usage; + /// Trust On First Use has been (automatically) enabled since this DateTime. + String? lastSeenPublicKey; + + bool get tofuVerified => + publicKey != null && + (lastSeenPublicKey == null || lastSeenPublicKey == publicKey); + bool get isValid => userId.isNotEmpty && publicKey != null && @@ -418,8 +425,22 @@ class CrossSigningKey extends SignableKey { throw Exception('setVerified called on invalid key'); } await super.setVerified(newVerified, sign); - await client.database - .setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!); + await client.database.setVerifiedUserCrossSigningKey( + newVerified, + userId, + publicKey!, + lastSeenPublicKey: lastSeenPublicKey, + ); + } + + Future updateLastSeenPublicKey() async { + lastSeenPublicKey = publicKey; + await client.database.setVerifiedUserCrossSigningKey( + verified, + userId, + publicKey!, + lastSeenPublicKey: publicKey, + ); } @override @@ -439,6 +460,7 @@ class CrossSigningKey extends SignableKey { final json = toJson(); identifier = key.publicKey; usage = json['usage'].cast(); + lastSeenPublicKey = json['last_seen_public_key'] as String?; } CrossSigningKey.fromDbJson(Map dbEntry, Client client) @@ -448,6 +470,7 @@ class CrossSigningKey extends SignableKey { usage = json['usage'].cast(); _verified = dbEntry['verified']; _blocked = dbEntry['blocked']; + lastSeenPublicKey = dbEntry['last_seen_public_key'] as String?; } CrossSigningKey.fromJson(Map json, Client client) @@ -457,6 +480,7 @@ class CrossSigningKey extends SignableKey { if (keys.isNotEmpty) { identifier = keys.values.first; } + lastSeenPublicKey = json['last_seen_public_key'] as String?; } } diff --git a/lib/src/utils/event_localizations.dart b/lib/src/utils/event_localizations.dart index 07ab25ab1..61871a2b5 100644 --- a/lib/src/utils/event_localizations.dart +++ b/lib/src/utils/event_localizations.dart @@ -305,5 +305,17 @@ abstract class EventLocalizations { event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n), ), PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded, + EventTypes.TofuNotification: (event, i18n, _) => + i18n.usersHaveChangedTheirKeys( + event.content + .tryGetList('users') + ?.map( + (userId) => event.room + .unsafeGetUserFromMemoryOrFallback(userId) + .calcDisplayname(i18n: i18n), + ) + .toList() ?? + [], + ), }; } diff --git a/lib/src/utils/matrix_default_localizations.dart b/lib/src/utils/matrix_default_localizations.dart index 93ed4e577..c05fc5949 100644 --- a/lib/src/utils/matrix_default_localizations.dart +++ b/lib/src/utils/matrix_default_localizations.dart @@ -327,4 +327,8 @@ class MatrixDefaultLocalizations extends MatrixLocalizations { @override String get pollHasBeenEnded => 'Poll has been ended'; + + @override + String usersHaveChangedTheirKeys(List users) => + '${users.join(', ')} has/have reset their encryption keys'; } diff --git a/lib/src/utils/matrix_localizations.dart b/lib/src/utils/matrix_localizations.dart index 4173aad6f..87e8d0a43 100644 --- a/lib/src/utils/matrix_localizations.dart +++ b/lib/src/utils/matrix_localizations.dart @@ -191,6 +191,8 @@ abstract class MatrixLocalizations { String startedAPoll(String senderName); String get pollHasBeenEnded; + + String usersHaveChangedTheirKeys(List users); } extension HistoryVisibilityDisplayString on HistoryVisibility { diff --git a/lib/src/utils/on_tofu_event.dart b/lib/src/utils/on_tofu_event.dart new file mode 100644 index 000000000..b0fdafcae --- /dev/null +++ b/lib/src/utils/on_tofu_event.dart @@ -0,0 +1,33 @@ +import 'package:matrix/matrix.dart'; + +Future sendTofuEvent(Room room, Set userIds) async { + await room.client.database.transaction(() async { + await room.client.handleSync( + SyncUpdate( + nextBatch: '', + rooms: RoomsUpdate( + join: { + room.id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + eventId: + '\$_local_event_${room.client.generateUniqueTransactionId()}', + content: { + 'body': + '${userIds.join(', ')} has/have reset their encryption keys', + 'users': userIds.toList(), + }, + type: EventTypes.TofuNotification, + senderId: room.client.userID!, + originServerTs: DateTime.now(), + ), + ], + ), + ), + }, + ), + ), + ); + }); +} diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 7615782e1..422a9c99a 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -688,6 +688,7 @@ void main() { '{}', false, false, + null, ); }); test('setVerifiedUserCrossSigningKey', () async { diff --git a/test/encryption/crypto_setup_test.dart b/test/encryption/crypto_setup_test.dart index 535e02b6b..59de9982c 100644 --- a/test/encryption/crypto_setup_test.dart +++ b/test/encryption/crypto_setup_test.dart @@ -69,6 +69,21 @@ void main() { expect(state.connected, true); }); + test('TOFU', () async { + final client = await getClient(); + await client.initCryptoIdentity(); + expect( + client.userDeviceKeys['@othertest:fakeServer.notExisting']!.masterKey! + .lastSeenPublicKey, + null, + ); + expect( + client.userDeviceKeys['@othertest:fakeServer.notExisting']!.masterKey! + .tofuVerified, + true, + ); + }); + test( 'initCryptoIdentity with passphrase', () async {