Skip to content

Commit e410e20

Browse files
committed
feat(BREAKING): Add onTofuEvent callback and mark master key as tofu verified when first used
1 parent 79357b8 commit e410e20

13 files changed

Lines changed: 189 additions & 18 deletions

doc/end-to-end-encryption.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,26 @@ identity and get a new key with `client.initCryptoIdentity()` at any time.
8888
> **key verification** to connect with another session which is already connected.
8989
>
9090
> The Client would then request all necessary secrets of your crypto identity
91-
> automatically via **to-device-messaging**.
91+
> automatically via **to-device-messaging**.
92+
93+
### Trust On First Use (Tofu)
94+
95+
With **Trust On First Use** you can inform the user when the crypto identity of
96+
a participant changes. This is usually checked when preparing the encryption
97+
before sending a message into a room. Therefore a Tofu Event is
98+
connected to a room but sent only once per user.
99+
100+
To enable Tofu, just implement the `onTofuEvent` callback in the client
101+
constructor:
102+
103+
```dart
104+
Client('Client Name',
105+
// ...
106+
onTofuEvent: (room, userIds) {
107+
print('$userIds have changed their crypto identity!');
108+
}
109+
);
110+
```
111+
112+
By default it sends a state event of type `sdk.matrix.dart.tofu_notification`
113+
into the room by using the function `sendTofuEvent()`.

lib/encryption/key_manager.dart

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,50 @@ class KeyManager {
309309
return true;
310310
}
311311

312+
// next check if the devices in the room changed
313+
final devicesToReceive = <DeviceKeys>[];
314+
final newDeviceKeys = await room.getUserDeviceKeys();
315+
final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys);
316+
// first check for user differences
317+
final oldUserIds = sess.devices.keys.toSet();
318+
final newUserIds = newDeviceKeyIds.keys.toSet();
319+
320+
// Update TOFU states:
321+
final onTofuEvent = client.onTofuEvent;
322+
if (onTofuEvent != null) {
323+
final nonTofuMasterKeys = newUserIds
324+
.where((userId) => userId != client.userID)
325+
.map((userId) => client.userDeviceKeys[userId]?.masterKey)
326+
.whereType<CrossSigningKey>()
327+
.where((key) => !key.tofuVerified && !key.verified);
328+
if (nonTofuMasterKeys.isNotEmpty) {
329+
// Inform about changed keys:
330+
final userIdsWithChangedMasterKeys = nonTofuMasterKeys
331+
.where((key) => key.lastSeenPublicKey != null)
332+
.map((key) => key.userId)
333+
.toSet();
334+
if (userIdsWithChangedMasterKeys.isNotEmpty) {
335+
onTofuEvent(room, userIdsWithChangedMasterKeys);
336+
}
337+
338+
// Update last seen public key for each master key:
339+
for (final masterKey in nonTofuMasterKeys) {
340+
if (masterKey.lastSeenPublicKey == null) {
341+
Logs().d(
342+
'Trust On First Use for ${masterKey.userId} master key',
343+
masterKey.publicKey,
344+
);
345+
} else {
346+
Logs().d(
347+
'${masterKey.userId} has a new master key',
348+
masterKey.publicKey,
349+
);
350+
}
351+
await masterKey.updateLastSeenPublicKey();
352+
}
353+
}
354+
}
355+
312356
if (!wipe) {
313357
// first check if it needs to be rotated
314358
final encryptionContent =
@@ -335,13 +379,6 @@ class KeyManager {
335379
}
336380

337381
if (!wipe) {
338-
// next check if the devices in the room changed
339-
final devicesToReceive = <DeviceKeys>[];
340-
final newDeviceKeys = await room.getUserDeviceKeys();
341-
final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys);
342-
// first check for user differences
343-
final oldUserIds = sess.devices.keys.toSet();
344-
final newUserIds = newDeviceKeyIds.keys.toSet();
345382
if (oldUserIds.difference(newUserIds).isNotEmpty) {
346383
// a user left the room, we must wipe the session
347384
wipe = true;

lib/matrix_api_lite/model/event_types.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,7 @@ abstract class EventTypes {
100100
static const String GroupCallMemberAssertedIdentity =
101101
'$GroupCallMember.asserted_identity';
102102
static const GroupCallMemberReaction = 'com.famedly.call.member.reaction';
103+
104+
// Internal
105+
static const String TofuNotification = 'sdk.matrix.dart.tofu_notification';
103106
}

lib/src/client.dart

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'package:matrix/src/models/timeline_chunk.dart';
2222
import 'package:matrix/src/utils/cached_stream_controller.dart';
2323
import 'package:matrix/src/utils/client_init_exception.dart';
2424
import 'package:matrix/src/utils/multilock.dart';
25+
import 'package:matrix/src/utils/on_tofu_event.dart';
2526
import 'package:matrix/src/utils/request_and_cache.dart';
2627
import 'package:matrix/src/utils/run_benchmarked.dart';
2728
import 'package:matrix/src/utils/run_in_root.dart';
@@ -192,6 +193,7 @@ class Client extends MatrixApi {
192193
this.customImageResizer,
193194
this.shareKeysWith = ShareKeysWith.crossVerifiedIfEnabled,
194195
this.enableDehydratedDevices = false,
196+
this.onTofuEvent = sendTofuEvent,
195197
this.receiptsPublicByDefault = true,
196198

197199
/// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout
@@ -376,6 +378,8 @@ class Client extends MatrixApi {
376378

377379
bool enableDehydratedDevices = false;
378380

381+
void Function(Room room, Set<String> userIds)? onTofuEvent;
382+
379383
final String dehydratedDeviceDisplayName;
380384

381385
/// Whether read receipts are sent as public receipts by default or just as private receipts.
@@ -3530,11 +3534,18 @@ class Client extends MatrixApi {
35303534
final oldKeys =
35313535
Map<String, CrossSigningKey>.from(userKeys.crossSigningKeys);
35323536
userKeys.crossSigningKeys = {};
3537+
3538+
final entry = CrossSigningKey.fromMatrixCrossSigningKey(
3539+
crossSigningKeyListEntry.value,
3540+
this,
3541+
);
3542+
35333543
// add the types we aren't handling atm back
35343544
for (final oldEntry in oldKeys.entries) {
35353545
if (!oldEntry.value.usage.contains(keyType)) {
35363546
userKeys.crossSigningKeys[oldEntry.key] = oldEntry.value;
35373547
} else {
3548+
entry.lastSeenPublicKey = oldEntry.value.lastSeenPublicKey;
35383549
// There is a previous cross-signing key with this usage, that we no
35393550
// longer need/use. Clear it from the database.
35403551
dbActions.add(
@@ -3543,10 +3554,6 @@ class Client extends MatrixApi {
35433554
);
35443555
}
35453556
}
3546-
final entry = CrossSigningKey.fromMatrixCrossSigningKey(
3547-
crossSigningKeyListEntry.value,
3548-
this,
3549-
);
35503557
final publicKey = entry.publicKey;
35513558
if (entry.isValid && publicKey != null) {
35523559
final oldKey = oldKeys[publicKey];
@@ -3571,6 +3578,7 @@ class Client extends MatrixApi {
35713578
json.encode(entry.toJson()),
35723579
entry.directVerified,
35733580
entry.blocked,
3581+
entry.lastSeenPublicKey,
35743582
),
35753583
);
35763584
}
@@ -4129,6 +4137,7 @@ class Client extends MatrixApi {
41294137
jsonEncode(crossSigningKey.toJson()),
41304138
crossSigningKey.directVerified,
41314139
crossSigningKey.blocked,
4140+
crossSigningKey.lastSeenPublicKey,
41324141
);
41334142
}
41344143
}

lib/src/database/database_api.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ abstract class DatabaseApi {
251251
String content,
252252
bool verified,
253253
bool blocked,
254+
String? lastSeenPublicKey,
254255
);
255256

256257
Future deleteFromToDeviceQueue(int id);
@@ -266,8 +267,9 @@ abstract class DatabaseApi {
266267
Future setVerifiedUserCrossSigningKey(
267268
bool verified,
268269
String userId,
269-
String publicKey,
270-
);
270+
String publicKey, {
271+
String? lastSeenPublicKey,
272+
});
271273

272274
Future setBlockedUserCrossSigningKey(
273275
bool blocked,

lib/src/database/matrix_sdk_database.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,14 +1074,18 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
10741074
Future<void> setVerifiedUserCrossSigningKey(
10751075
bool verified,
10761076
String userId,
1077-
String publicKey,
1078-
) async {
1077+
String publicKey, {
1078+
String? lastSeenPublicKey,
1079+
}) async {
10791080
final raw = copyMap(
10801081
(await _userCrossSigningKeysBox
10811082
.get(TupleKey(userId, publicKey).toString())) ??
10821083
{},
10831084
);
10841085
raw['verified'] = verified;
1086+
if (lastSeenPublicKey != null) {
1087+
raw['last_seen_public_key'] = lastSeenPublicKey;
1088+
}
10851089
await _userCrossSigningKeysBox.put(
10861090
TupleKey(userId, publicKey).toString(),
10871091
raw,
@@ -1421,6 +1425,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
14211425
String content,
14221426
bool verified,
14231427
bool blocked,
1428+
String? lastSeenPublicKey,
14241429
) async {
14251430
await _userCrossSigningKeysBox.put(
14261431
TupleKey(userId, publicKey).toString(),
@@ -1430,6 +1435,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
14301435
'content': content,
14311436
'verified': verified,
14321437
'blocked': blocked,
1438+
if (lastSeenPublicKey != null)
1439+
'last_seen_public_key': lastSeenPublicKey,
14331440
},
14341441
);
14351442
}

lib/src/utils/device_keys_list.dart

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,13 @@ class CrossSigningKey extends SignableKey {
392392
String? get publicKey => identifier;
393393
late List<String> usage;
394394

395+
/// Trust On First Use has been (automatically) enabled since this DateTime.
396+
String? lastSeenPublicKey;
397+
398+
bool get tofuVerified =>
399+
publicKey != null &&
400+
(lastSeenPublicKey == null || lastSeenPublicKey == publicKey);
401+
395402
bool get isValid =>
396403
userId.isNotEmpty &&
397404
publicKey != null &&
@@ -404,8 +411,22 @@ class CrossSigningKey extends SignableKey {
404411
throw Exception('setVerified called on invalid key');
405412
}
406413
await super.setVerified(newVerified, sign);
407-
await client.database
408-
.setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!);
414+
await client.database.setVerifiedUserCrossSigningKey(
415+
newVerified,
416+
userId,
417+
publicKey!,
418+
lastSeenPublicKey: lastSeenPublicKey,
419+
);
420+
}
421+
422+
Future<void> updateLastSeenPublicKey() async {
423+
lastSeenPublicKey = publicKey;
424+
await client.database.setVerifiedUserCrossSigningKey(
425+
verified,
426+
userId,
427+
publicKey!,
428+
lastSeenPublicKey: publicKey,
429+
);
409430
}
410431

411432
@override
@@ -425,6 +446,7 @@ class CrossSigningKey extends SignableKey {
425446
final json = toJson();
426447
identifier = key.publicKey;
427448
usage = json['usage'].cast<String>();
449+
lastSeenPublicKey = json['last_seen_public_key'] as String?;
428450
}
429451

430452
CrossSigningKey.fromDbJson(Map<String, dynamic> dbEntry, Client client)
@@ -434,6 +456,7 @@ class CrossSigningKey extends SignableKey {
434456
usage = json['usage'].cast<String>();
435457
_verified = dbEntry['verified'];
436458
_blocked = dbEntry['blocked'];
459+
lastSeenPublicKey = dbEntry['last_seen_public_key'] as String?;
437460
}
438461

439462
CrossSigningKey.fromJson(Map<String, dynamic> json, Client client)
@@ -443,6 +466,7 @@ class CrossSigningKey extends SignableKey {
443466
if (keys.isNotEmpty) {
444467
identifier = keys.values.first;
445468
}
469+
lastSeenPublicKey = json['last_seen_public_key'] as String?;
446470
}
447471
}
448472

lib/src/utils/event_localizations.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,5 +291,17 @@ abstract class EventLocalizations {
291291
event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n),
292292
),
293293
PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded,
294+
EventTypes.TofuNotification: (event, i18n, _) =>
295+
i18n.usersHaveChangedTheirKeys(
296+
event.content
297+
.tryGetList<String>('users')
298+
?.map(
299+
(userId) => event.room
300+
.unsafeGetUserFromMemoryOrFallback(userId)
301+
.calcDisplayname(i18n: i18n),
302+
)
303+
.toList() ??
304+
<String>[],
305+
),
294306
};
295307
}

lib/src/utils/matrix_default_localizations.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,8 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
313313

314314
@override
315315
String get pollHasBeenEnded => 'Poll has been ended';
316+
317+
@override
318+
String usersHaveChangedTheirKeys(List<String> users) =>
319+
'${users.join(', ')} has/have reset their encryption keys';
316320
}

lib/src/utils/matrix_localizations.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ abstract class MatrixLocalizations {
177177
String startedAPoll(String senderName);
178178

179179
String get pollHasBeenEnded;
180+
181+
String usersHaveChangedTheirKeys(List<String> users);
180182
}
181183

182184
extension HistoryVisibilityDisplayString on HistoryVisibility {

0 commit comments

Comments
 (0)