Skip to content

Commit 6d49704

Browse files
committed
feat(BREAKING): Add onTofuEvent callback and mark master key as tofu verified when first used
1 parent 2ba4922 commit 6d49704

13 files changed

Lines changed: 235 additions & 18 deletions

doc/end-to-end-encryption.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,26 @@ identity and get a new key with `client.initCryptoIdentity()` at any time.
8282
> **key verification** to connect with another session which is already connected.
8383
>
8484
> The Client would then request all necessary secrets of your crypto identity
85-
> automatically via **to-device-messaging**.
85+
> automatically via **to-device-messaging**.
86+
87+
### Trust On First Use (Tofu)
88+
89+
With **Trust On First Use** you can inform the user when the crypto identity of
90+
a participant changes. This is usually checked when preparing the encryption
91+
before sending a message into a room. Therefore a Tofu Event is
92+
connected to a room but sent only once per user.
93+
94+
To enable Tofu, just implement the `onTofuEvent` callback in the client
95+
constructor:
96+
97+
```dart
98+
Client('Client Name',
99+
// ...
100+
onTofuEvent: (room, userIds) {
101+
print('$userIds have changed their crypto identity!');
102+
}
103+
);
104+
```
105+
106+
By default it sends a state event of type `sdk.matrix.dart.tofu_notification`
107+
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
@@ -323,6 +323,50 @@ class KeyManager {
323323
return true;
324324
}
325325

326+
// next check if the devices in the room changed
327+
final devicesToReceive = <DeviceKeys>[];
328+
final newDeviceKeys = await room.getUserDeviceKeys();
329+
final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys);
330+
// first check for user differences
331+
final oldUserIds = sess.devices.keys.toSet();
332+
final newUserIds = newDeviceKeyIds.keys.toSet();
333+
334+
// Update TOFU states:
335+
final onTofuEvent = client.onTofuEvent;
336+
if (onTofuEvent != null) {
337+
final nonTofuMasterKeys = newUserIds
338+
.where((userId) => userId != client.userID)
339+
.map((userId) => client.userDeviceKeys[userId]?.masterKey)
340+
.whereType<CrossSigningKey>()
341+
.where((key) => !key.tofuVerified && !key.verified);
342+
if (nonTofuMasterKeys.isNotEmpty) {
343+
// Inform about changed keys:
344+
final userIdsWithChangedMasterKeys = nonTofuMasterKeys
345+
.where((key) => key.lastSeenPublicKey != null)
346+
.map((key) => key.userId)
347+
.toSet();
348+
if (userIdsWithChangedMasterKeys.isNotEmpty) {
349+
onTofuEvent(room, userIdsWithChangedMasterKeys);
350+
}
351+
352+
// Update last seen public key for each master key:
353+
for (final masterKey in nonTofuMasterKeys) {
354+
if (masterKey.lastSeenPublicKey == null) {
355+
Logs().d(
356+
'Trust On First Use for ${masterKey.userId} master key',
357+
masterKey.publicKey,
358+
);
359+
} else {
360+
Logs().d(
361+
'${masterKey.userId} has a new master key',
362+
masterKey.publicKey,
363+
);
364+
}
365+
await masterKey.updateLastSeenPublicKey();
366+
}
367+
}
368+
}
369+
326370
if (!wipe) {
327371
// first check if it needs to be rotated
328372
final encryptionContent =
@@ -349,13 +393,6 @@ class KeyManager {
349393
}
350394

351395
if (!wipe) {
352-
// next check if the devices in the room changed
353-
final devicesToReceive = <DeviceKeys>[];
354-
final newDeviceKeys = await room.getUserDeviceKeys();
355-
final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys);
356-
// first check for user differences
357-
final oldUserIds = sess.devices.keys.toSet();
358-
final newUserIds = newDeviceKeyIds.keys.toSet();
359396
if (oldUserIds.difference(newUserIds).isNotEmpty) {
360397
// a user left the room, we must wipe the session
361398
wipe = true;

lib/matrix_api_lite/model/event_types.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,7 @@ abstract class EventTypes {
119119
static const String GroupCallMemberAssertedIdentity =
120120
'$GroupCallMember.asserted_identity';
121121
static const GroupCallMemberReaction = 'com.famedly.call.member.reaction';
122+
123+
// Internal
124+
static const String TofuNotification = 'sdk.matrix.dart.tofu_notification';
122125
}

lib/src/client.dart

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import 'package:matrix/src/models/timeline_chunk.dart';
3636
import 'package:matrix/src/utils/cached_stream_controller.dart';
3737
import 'package:matrix/src/utils/client_init_exception.dart';
3838
import 'package:matrix/src/utils/multilock.dart';
39+
import 'package:matrix/src/utils/on_tofu_event.dart';
3940
import 'package:matrix/src/utils/request_and_cache.dart';
4041
import 'package:matrix/src/utils/run_benchmarked.dart';
4142
import 'package:matrix/src/utils/run_in_root.dart';
@@ -199,6 +200,7 @@ class Client extends MatrixApi {
199200
this.customImageResizer,
200201
this.shareKeysWith = ShareKeysWith.crossVerifiedIfEnabled,
201202
this.enableDehydratedDevices = false,
203+
this.onTofuEvent = sendTofuEvent,
202204
this.receiptsPublicByDefault = true,
203205

204206
/// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout
@@ -382,6 +384,8 @@ class Client extends MatrixApi {
382384

383385
bool enableDehydratedDevices = false;
384386

387+
void Function(Room room, Set<String> userIds)? onTofuEvent;
388+
385389
final String dehydratedDeviceDisplayName;
386390

387391
/// Whether read receipts are sent as public receipts by default or just as private receipts.
@@ -3536,11 +3540,18 @@ class Client extends MatrixApi {
35363540
final oldKeys =
35373541
Map<String, CrossSigningKey>.from(userKeys.crossSigningKeys);
35383542
userKeys.crossSigningKeys = {};
3543+
3544+
final entry = CrossSigningKey.fromMatrixCrossSigningKey(
3545+
crossSigningKeyListEntry.value,
3546+
this,
3547+
);
3548+
35393549
// add the types we aren't handling atm back
35403550
for (final oldEntry in oldKeys.entries) {
35413551
if (!oldEntry.value.usage.contains(keyType)) {
35423552
userKeys.crossSigningKeys[oldEntry.key] = oldEntry.value;
35433553
} else {
3554+
entry.lastSeenPublicKey = oldEntry.value.lastSeenPublicKey;
35443555
// There is a previous cross-signing key with this usage, that we no
35453556
// longer need/use. Clear it from the database.
35463557
dbActions.add(
@@ -3549,10 +3560,6 @@ class Client extends MatrixApi {
35493560
);
35503561
}
35513562
}
3552-
final entry = CrossSigningKey.fromMatrixCrossSigningKey(
3553-
crossSigningKeyListEntry.value,
3554-
this,
3555-
);
35563563
final publicKey = entry.publicKey;
35573564
if (entry.isValid && publicKey != null) {
35583565
final oldKey = oldKeys[publicKey];
@@ -3577,6 +3584,7 @@ class Client extends MatrixApi {
35773584
json.encode(entry.toJson()),
35783585
entry.directVerified,
35793586
entry.blocked,
3587+
entry.lastSeenPublicKey,
35803588
),
35813589
);
35823590
}
@@ -4135,6 +4143,7 @@ class Client extends MatrixApi {
41354143
jsonEncode(crossSigningKey.toJson()),
41364144
crossSigningKey.directVerified,
41374145
crossSigningKey.blocked,
4146+
crossSigningKey.lastSeenPublicKey,
41384147
);
41394148
}
41404149
}

lib/src/database/database_api.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ abstract class DatabaseApi {
265265
String content,
266266
bool verified,
267267
bool blocked,
268+
String? lastSeenPublicKey,
268269
);
269270

270271
Future deleteFromToDeviceQueue(int id);
@@ -280,8 +281,9 @@ abstract class DatabaseApi {
280281
Future setVerifiedUserCrossSigningKey(
281282
bool verified,
282283
String userId,
283-
String publicKey,
284-
);
284+
String publicKey, {
285+
String? lastSeenPublicKey,
286+
});
285287

286288
Future setBlockedUserCrossSigningKey(
287289
bool blocked,

lib/src/database/matrix_sdk_database.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,14 +1088,18 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
10881088
Future<void> setVerifiedUserCrossSigningKey(
10891089
bool verified,
10901090
String userId,
1091-
String publicKey,
1092-
) async {
1091+
String publicKey, {
1092+
String? lastSeenPublicKey,
1093+
}) async {
10931094
final raw = copyMap(
10941095
(await _userCrossSigningKeysBox
10951096
.get(TupleKey(userId, publicKey).toString())) ??
10961097
{},
10971098
);
10981099
raw['verified'] = verified;
1100+
if (lastSeenPublicKey != null) {
1101+
raw['last_seen_public_key'] = lastSeenPublicKey;
1102+
}
10991103
await _userCrossSigningKeysBox.put(
11001104
TupleKey(userId, publicKey).toString(),
11011105
raw,
@@ -1435,6 +1439,7 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
14351439
String content,
14361440
bool verified,
14371441
bool blocked,
1442+
String? lastSeenPublicKey,
14381443
) async {
14391444
await _userCrossSigningKeysBox.put(
14401445
TupleKey(userId, publicKey).toString(),
@@ -1444,6 +1449,8 @@ class MatrixSdkDatabase extends DatabaseApi with DatabaseFileStorage {
14441449
'content': content,
14451450
'verified': verified,
14461451
'blocked': blocked,
1452+
if (lastSeenPublicKey != null)
1453+
'last_seen_public_key': lastSeenPublicKey,
14471454
},
14481455
);
14491456
}

lib/src/utils/device_keys_list.dart

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ class CrossSigningKey extends SignableKey {
406406
String? get publicKey => identifier;
407407
late List<String> usage;
408408

409+
/// Trust On First Use has been (automatically) enabled since this DateTime.
410+
String? lastSeenPublicKey;
411+
412+
bool get tofuVerified => publicKey != null && lastSeenPublicKey == publicKey;
413+
409414
bool get isValid =>
410415
userId.isNotEmpty &&
411416
publicKey != null &&
@@ -418,8 +423,22 @@ class CrossSigningKey extends SignableKey {
418423
throw Exception('setVerified called on invalid key');
419424
}
420425
await super.setVerified(newVerified, sign);
421-
await client.database
422-
.setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!);
426+
await client.database.setVerifiedUserCrossSigningKey(
427+
newVerified,
428+
userId,
429+
publicKey!,
430+
lastSeenPublicKey: lastSeenPublicKey,
431+
);
432+
}
433+
434+
Future<void> updateLastSeenPublicKey() async {
435+
lastSeenPublicKey = publicKey;
436+
await client.database.setVerifiedUserCrossSigningKey(
437+
verified,
438+
userId,
439+
publicKey!,
440+
lastSeenPublicKey: publicKey,
441+
);
423442
}
424443

425444
@override
@@ -439,6 +458,7 @@ class CrossSigningKey extends SignableKey {
439458
final json = toJson();
440459
identifier = key.publicKey;
441460
usage = json['usage'].cast<String>();
461+
lastSeenPublicKey = json['last_seen_public_key'] as String?;
442462
}
443463

444464
CrossSigningKey.fromDbJson(Map<String, dynamic> dbEntry, Client client)
@@ -448,6 +468,7 @@ class CrossSigningKey extends SignableKey {
448468
usage = json['usage'].cast<String>();
449469
_verified = dbEntry['verified'];
450470
_blocked = dbEntry['blocked'];
471+
lastSeenPublicKey = dbEntry['last_seen_public_key'] as String?;
451472
}
452473

453474
CrossSigningKey.fromJson(Map<String, dynamic> json, Client client)
@@ -457,6 +478,7 @@ class CrossSigningKey extends SignableKey {
457478
if (keys.isNotEmpty) {
458479
identifier = keys.values.first;
459480
}
481+
lastSeenPublicKey = json['last_seen_public_key'] as String?;
460482
}
461483
}
462484

lib/src/utils/event_localizations.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,5 +305,17 @@ abstract class EventLocalizations {
305305
event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n),
306306
),
307307
PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded,
308+
EventTypes.TofuNotification: (event, i18n, _) =>
309+
i18n.usersHaveChangedTheirKeys(
310+
event.content
311+
.tryGetList<String>('users')
312+
?.map(
313+
(userId) => event.room
314+
.unsafeGetUserFromMemoryOrFallback(userId)
315+
.calcDisplayname(i18n: i18n),
316+
)
317+
.toList() ??
318+
<String>[],
319+
),
308320
};
309321
}

lib/src/utils/matrix_default_localizations.dart

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

328328
@override
329329
String get pollHasBeenEnded => 'Poll has been ended';
330+
331+
@override
332+
String usersHaveChangedTheirKeys(List<String> users) =>
333+
'${users.join(', ')} has/have reset their encryption keys';
330334
}

lib/src/utils/matrix_localizations.dart

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

193193
String get pollHasBeenEnded;
194+
195+
String usersHaveChangedTheirKeys(List<String> users);
194196
}
195197

196198
extension HistoryVisibilityDisplayString on HistoryVisibility {

0 commit comments

Comments
 (0)