Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion doc/end-to-end-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
> 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()`.
51 changes: 44 additions & 7 deletions lib/encryption/key_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,50 @@ class KeyManager {
return true;
}

// next check if the devices in the room changed
final devicesToReceive = <DeviceKeys>[];
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<CrossSigningKey>()
.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 =
Expand All @@ -349,13 +393,6 @@ class KeyManager {
}

if (!wipe) {
// next check if the devices in the room changed
final devicesToReceive = <DeviceKeys>[];
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;
Expand Down
3 changes: 3 additions & 0 deletions lib/matrix_api_lite/model/event_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
17 changes: 13 additions & 4 deletions lib/src/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -382,6 +384,8 @@ class Client extends MatrixApi {

bool enableDehydratedDevices = false;

void Function(Room room, Set<String> userIds)? onTofuEvent;

final String dehydratedDeviceDisplayName;

/// Whether read receipts are sent as public receipts by default or just as private receipts.
Expand Down Expand Up @@ -3536,11 +3540,18 @@ class Client extends MatrixApi {
final oldKeys =
Map<String, CrossSigningKey>.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(
Expand All @@ -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];
Expand All @@ -3577,6 +3584,7 @@ class Client extends MatrixApi {
json.encode(entry.toJson()),
entry.directVerified,
entry.blocked,
entry.lastSeenPublicKey,
),
);
}
Expand Down Expand Up @@ -4135,6 +4143,7 @@ class Client extends MatrixApi {
jsonEncode(crossSigningKey.toJson()),
crossSigningKey.directVerified,
crossSigningKey.blocked,
crossSigningKey.lastSeenPublicKey,
);
}
}
Expand Down
6 changes: 4 additions & 2 deletions lib/src/database/database_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ abstract class DatabaseApi {
String content,
bool verified,
bool blocked,
String? lastSeenPublicKey,
);

Future deleteFromToDeviceQueue(int id);
Expand All @@ -280,8 +281,9 @@ abstract class DatabaseApi {
Future setVerifiedUserCrossSigningKey(
bool verified,
String userId,
String publicKey,
);
String publicKey, {
String? lastSeenPublicKey,
});

Future setBlockedUserCrossSigningKey(
bool blocked,
Expand Down
11 changes: 9 additions & 2 deletions lib/src/database/matrix_sdk_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1088,14 +1088,18 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
Future<void> 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,
Expand Down Expand Up @@ -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(),
Expand All @@ -1444,6 +1449,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
'content': content,
'verified': verified,
'blocked': blocked,
if (lastSeenPublicKey != null)
'last_seen_public_key': lastSeenPublicKey,
},
);
}
Expand Down
28 changes: 26 additions & 2 deletions lib/src/utils/device_keys_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ class CrossSigningKey extends SignableKey {
String? get publicKey => identifier;
late List<String> 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 &&
Expand All @@ -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<void> updateLastSeenPublicKey() async {
lastSeenPublicKey = publicKey;
await client.database.setVerifiedUserCrossSigningKey(
verified,
userId,
publicKey!,
lastSeenPublicKey: publicKey,
);
}

@override
Expand All @@ -439,6 +460,7 @@ class CrossSigningKey extends SignableKey {
final json = toJson();
identifier = key.publicKey;
usage = json['usage'].cast<String>();
lastSeenPublicKey = json['last_seen_public_key'] as String?;
}

CrossSigningKey.fromDbJson(Map<String, dynamic> dbEntry, Client client)
Expand All @@ -448,6 +470,7 @@ class CrossSigningKey extends SignableKey {
usage = json['usage'].cast<String>();
_verified = dbEntry['verified'];
_blocked = dbEntry['blocked'];
lastSeenPublicKey = dbEntry['last_seen_public_key'] as String?;
}

CrossSigningKey.fromJson(Map<String, dynamic> json, Client client)
Expand All @@ -457,6 +480,7 @@ class CrossSigningKey extends SignableKey {
if (keys.isNotEmpty) {
identifier = keys.values.first;
}
lastSeenPublicKey = json['last_seen_public_key'] as String?;
}
}

Expand Down
12 changes: 12 additions & 0 deletions lib/src/utils/event_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>('users')
?.map(
(userId) => event.room
.unsafeGetUserFromMemoryOrFallback(userId)
.calcDisplayname(i18n: i18n),
)
.toList() ??
<String>[],
),
};
}
4 changes: 4 additions & 0 deletions lib/src/utils/matrix_default_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,8 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {

@override
String get pollHasBeenEnded => 'Poll has been ended';

@override
String usersHaveChangedTheirKeys(List<String> users) =>
'${users.join(', ')} has/have reset their encryption keys';
}
2 changes: 2 additions & 0 deletions lib/src/utils/matrix_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ abstract class MatrixLocalizations {
String startedAPoll(String senderName);

String get pollHasBeenEnded;

String usersHaveChangedTheirKeys(List<String> users);
}

extension HistoryVisibilityDisplayString on HistoryVisibility {
Expand Down
33 changes: 33 additions & 0 deletions lib/src/utils/on_tofu_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'package:matrix/matrix.dart';

Future<void> sendTofuEvent(Room room, Set<String> 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(),
),
],
),
),
},
),
),
);
});
}
1 change: 1 addition & 0 deletions test/database_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ void main() {
'{}',
false,
false,
null,
);
});
test('setVerifiedUserCrossSigningKey', () async {
Expand Down
15 changes: 15 additions & 0 deletions test/encryption/crypto_setup_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading