Skip to content

Commit d72a3d6

Browse files
fix: load keys for decryption separately for [Room].searchEvents
1 parent 79357b8 commit d72a3d6

3 files changed

Lines changed: 174 additions & 26 deletions

File tree

lib/encryption/key_manager.dart

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ class KeyManager {
2828
final _inboundGroupSessions = <String, Map<String, SessionKey>>{};
2929
final _outboundGroupSessions = <String, OutboundGroupSession>{};
3030
final Set<String> _loadedOutboundGroupSessions = <String>{};
31-
final Set<String> _requestedSessionIds = <String>{};
31+
final Map<String, Completer<void>> _requestedSessionIdCompleters =
32+
<String, Completer<void>>{};
3233

3334
KeyManager(this.encryption) {
3435
encryption.ssss.setValidator(megolmKey, (String secret) async {
@@ -49,7 +50,7 @@ class KeyManager {
4950
encryption.ssss.setCacheCallback(megolmKey, (String secret) {
5051
// we got a megolm key cached, clear our requested keys and try to re-decrypt
5152
// last events
52-
_requestedSessionIds.clear();
53+
_requestedSessionIdCompleters.clear();
5354
for (final room in client.rooms) {
5455
final lastEvent = room.lastEvent;
5556
if (lastEvent != null &&
@@ -219,30 +220,44 @@ class KeyManager {
219220
}
220221

221222
/// Attempt auto-request for a key
222-
void maybeAutoRequest(
223+
FutureOr<void> maybeAutoRequest(
223224
String roomId,
224225
String sessionId,
225226
String? senderKey, {
226227
bool tryOnlineBackup = true,
227228
bool onlineKeyBackupOnly = true,
228-
}) {
229+
bool awaitRequest = false,
230+
}) async {
229231
final room = client.getRoomById(roomId);
230232
final requestIdent = '$roomId|$sessionId';
231-
if (room != null &&
232-
!_requestedSessionIds.contains(requestIdent) &&
233-
!client.isUnknownSession) {
234-
// do e2ee recovery
235-
_requestedSessionIds.add(requestIdent);
236-
237-
runInRoot(
238-
() async => request(
233+
if (room != null && !client.isUnknownSession) {
234+
final existingReqCompleter = _requestedSessionIdCompleters[requestIdent];
235+
if (existingReqCompleter != null) {
236+
if (awaitRequest) {
237+
await existingReqCompleter.future;
238+
}
239+
// return if already exists (after awaiting completion if required)
240+
return;
241+
}
242+
243+
Future<void> requestFn() async {
244+
final completer = Completer<void>();
245+
_requestedSessionIdCompleters[requestIdent] = completer;
246+
await request(
239247
room,
240248
sessionId,
241249
senderKey,
242250
tryOnlineBackup: tryOnlineBackup,
243251
onlineKeyBackupOnly: onlineKeyBackupOnly,
244-
),
245-
);
252+
);
253+
completer.complete();
254+
}
255+
256+
if (awaitRequest) {
257+
await requestFn();
258+
} else {
259+
runInRoot(requestFn);
260+
}
246261
}
247262
}
248263

@@ -744,9 +759,13 @@ class KeyManager {
744759
bool tryOnlineBackup = true,
745760
bool onlineKeyBackupOnly = false,
746761
}) async {
762+
Logs().i(
763+
'[KeyManager] Requesting session key for $sessionId for room ${room.id}',
764+
);
747765
if (tryOnlineBackup && await isCached()) {
748766
// let's first check our online key backup store thingy...
749-
final hadPreviously = getInboundGroupSession(room.id, sessionId) != null;
767+
final hadSessionBefore =
768+
getInboundGroupSession(room.id, sessionId) != null;
750769
try {
751770
await loadSingleKey(room.id, sessionId);
752771
} catch (err, stacktrace) {
@@ -762,10 +781,17 @@ class KeyManager {
762781
);
763782
}
764783
}
765-
// TODO: also don't request from others if we have an index of 0 now
766-
if (!hadPreviously &&
767-
getInboundGroupSession(room.id, sessionId) != null) {
768-
return; // we managed to load the session from online backup, no need to care about it now
784+
785+
final storedSession = getInboundGroupSession(room.id, sessionId);
786+
// We loaded *something* from backup we didn't have before — peers are
787+
// unlikely to do better, so don't spam to-device requests.
788+
final loadedFreshFromBackup = !hadSessionBefore && storedSession != null;
789+
// We have the session from its very first index; peers literally cannot
790+
// give us anything better.
791+
final hasFullSession =
792+
storedSession?.inboundGroupSession?.firstKnownIndex == 0;
793+
if (loadedFreshFromBackup || hasFullSession) {
794+
return;
769795
}
770796
}
771797
if (onlineKeyBackupOnly) {

lib/src/room.dart

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2837,15 +2837,41 @@ class Room {
28372837
.map((matrixEvent) => Event.fromMatrixEvent(matrixEvent, this))
28382838
.toList();
28392839

2840+
/// Attempt decrypting the event, if it fails, load the key and try again once.
2841+
/// Returns null if event cannot be decrypted.
2842+
Future<Event?> loadKeysAndDecryptEvent(
2843+
Event event, {
2844+
bool keyAlreadyLoaded = false,
2845+
}) async {
2846+
final decrypted = await client.encryption!.decryptRoomEvent(
2847+
event,
2848+
store: false,
2849+
updateType: EventUpdateType.history,
2850+
);
2851+
if (decrypted.type != EventTypes.Encrypted) {
2852+
return decrypted;
2853+
} else if (!keyAlreadyLoaded) {
2854+
final content = event.parsedRoomEncryptedContent;
2855+
if (content.sessionId != null) {
2856+
await client.encryption!.keyManager.maybeAutoRequest(
2857+
id,
2858+
content.sessionId!,
2859+
content.senderKey,
2860+
tryOnlineBackup: true,
2861+
onlineKeyBackupOnly: true,
2862+
awaitRequest: true,
2863+
);
2864+
return loadKeysAndDecryptEvent(event, keyAlreadyLoaded: true);
2865+
}
2866+
}
2867+
return null;
2868+
}
2869+
28402870
// Decrypt all events one after another:
28412871
for (final (index, event) in events.indexed) {
2842-
if (event.type == EventTypes.Encrypted) {
2843-
final decrypted = await client.encryption?.decryptRoomEvent(
2844-
event,
2845-
store: false,
2846-
updateType: EventUpdateType.history,
2847-
);
2848-
if (decrypted != null && decrypted.type != EventTypes.Encrypted) {
2872+
if (event.type == EventTypes.Encrypted && client.encryption != null) {
2873+
final decrypted = await loadKeysAndDecryptEvent(event);
2874+
if (decrypted != null) {
28492875
events[index] = decrypted;
28502876
}
28512877
}

test/encryption/online_key_backup_test.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,102 @@ void main() {
121121
expect(ret != null, true);
122122
});
123123

124+
test(
125+
'Room.searchEvents decrypts an event whose session is only in online key backup',
126+
() async {
127+
// 1. Fresh outbound + inbound megolm session the client has never seen.
128+
final outbound = vod.GroupSession();
129+
final inbound = vod.InboundGroupSession(outbound.sessionKey);
130+
final newSessionId = inbound.sessionId;
131+
final newSenderKey = client.identityKey;
132+
133+
// 2. Real ciphertext that the matching inbound session can decrypt.
134+
final ciphertext = outbound.encrypt(
135+
json.encode({
136+
'type': 'm.room.message',
137+
'content': {
138+
'msgtype': 'm.text',
139+
'body': 'a needle in the encrypted haystack',
140+
},
141+
}),
142+
);
143+
144+
// 3. Encrypt the inbound session for the FakeMatrixApi backup using
145+
// the same backup public key the FakeMatrixApi advertises.
146+
const backupPubKey = 'GXYaxqhNhUK28zUdxOmEsFRguz+PzBsDlTLlF0O0RkM';
147+
final encryptor = vod.PkEncryption.fromPublicKey(
148+
vod.Curve25519PublicKey.fromBase64(backupPubKey),
149+
);
150+
final encryptedSession = encryptor.encrypt(
151+
json.encode({
152+
'algorithm': AlgorithmTypes.megolmV1AesSha2,
153+
'forwarding_curve25519_key_chain': <String>[],
154+
'sender_key': newSenderKey,
155+
'sender_claimed_keys': {'ed25519': client.fingerprintKey},
156+
'session_key': inbound.exportAt(0),
157+
}),
158+
);
159+
final (ct, mac, ephemeral) = encryptedSession.toBase64();
160+
161+
// 4. A fresh room that exists in client.rooms (required for
162+
// keyManager.maybeAutoRequest's lookup) and has no DB history.
163+
const newRoomId = '!searchBackupOnly:example.com';
164+
final newRoom = Room(client: client, id: newRoomId, prev_batch: '');
165+
client.rooms.add(newRoom);
166+
167+
// 5. Mock the online key backup GET for this specific session.
168+
FakeMatrixApi.currentApi!.api['GET']![
169+
'/client/v3/room_keys/keys/${Uri.encodeComponent(newRoomId)}/${Uri.encodeComponent(newSessionId)}?version=5'] =
170+
(_) => {
171+
'first_message_index': 0,
172+
'forwarded_count': 0,
173+
'is_verified': true,
174+
'session_data': {
175+
'ephemeral': ephemeral,
176+
'ciphertext': ct,
177+
'mac': mac,
178+
},
179+
};
180+
181+
// 6. Mock /messages with our encrypted event.
182+
FakeMatrixApi.currentApi!.api['GET']![
183+
'/client/v3/rooms/${Uri.encodeComponent(newRoomId)}/messages?from&dir=b&limit=1000&filter=%7B%22types%22%3A%5B%22m.room.message%22%2C%22m.room.encrypted%22%5D%7D'] =
184+
(_) => {
185+
'chunk': [
186+
{
187+
'content': {
188+
'algorithm': AlgorithmTypes.megolmV1AesSha2,
189+
'sender_key': newSenderKey,
190+
'session_id': newSessionId,
191+
'ciphertext': ciphertext,
192+
'device_id': client.deviceID,
193+
},
194+
'type': EventTypes.Encrypted,
195+
'event_id': '\$searchEnc',
196+
'origin_server_ts': 1432735824653,
197+
'sender': '@alice:example.com',
198+
},
199+
],
200+
'end': 't_search_end',
201+
'start': 't_search_start',
202+
};
203+
204+
// Pre-condition: the session is in neither memory nor DB.
205+
expect(
206+
client.encryption!.keyManager
207+
.getInboundGroupSession(newRoomId, newSessionId),
208+
isNull,
209+
);
210+
211+
final result = await newRoom.searchEvents(searchFunc: (_) => true);
212+
213+
// The event was decrypted via the backup-load path that searchEvents
214+
// performs internally.
215+
final decrypted = result.events.where((e) => e.eventId == '\$searchEnc');
216+
expect(decrypted, isNotEmpty);
217+
expect(decrypted.first.body, 'a needle in the encrypted haystack');
218+
});
219+
124220
test('dispose client', () async {
125221
await client.dispose(closeDatabase: false);
126222
});

0 commit comments

Comments
 (0)