Skip to content

Commit a5fbcf1

Browse files
authored
Merge pull request #5322 from matrix-org/poljar/shared-history/out-of-order
feat(sdk): Add a tasks that listens for historic room keys if they arrive out of order
2 parents 8923e58 + f14994b commit a5fbcf1

File tree

13 files changed

+641
-48
lines changed

13 files changed

+641
-48
lines changed

crates/matrix-sdk/CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ All notable changes to this project will be documented in this file.
66

77
## [Unreleased] - ReleaseDate
88

9-
### Breaking changes:
9+
### Features
10+
11+
- Add support to accept historic room key bundles that arrive out of order, i.e.
12+
the bundle arrives after the invite has already been accepted.
13+
([#5322](https://github.com/matrix-org/matrix-rust-sdk/pull/5322))
1014

11-
- `OAuth::login` now allows requesting additional scopes for the authorization code grant.
15+
- [**breaking**] `OAuth::login` now allows requesting additional scopes for the authorization code grant.
1216
([#5395](https://github.com/matrix-org/matrix-rust-sdk/pull/5395))
1317

1418
## [0.13.0] - 2025-07-10

crates/matrix-sdk/src/authentication/matrix/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ impl MatrixAuth {
802802
_ => None,
803803
};
804804

805-
self.client.encryption().spawn_initialization_task(auth_data);
805+
self.client.encryption().spawn_initialization_task(auth_data).await;
806806
}
807807

808808
Ok(())

crates/matrix-sdk/src/authentication/oauth/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ impl OAuth {
817817
}
818818

819819
#[cfg(feature = "e2e-encryption")]
820-
self.client.encryption().spawn_initialization_task(None);
820+
self.client.encryption().spawn_initialization_task(None).await;
821821

822822
Ok(())
823823
}
@@ -1031,7 +1031,7 @@ impl OAuth {
10311031
self.enable_cross_process_lock().await.map_err(OAuthError::from)?;
10321032

10331033
#[cfg(feature = "e2e-encryption")]
1034-
self.client.encryption().spawn_initialization_task(None);
1034+
self.client.encryption().spawn_initialization_task(None).await;
10351035
}
10361036

10371037
Ok(())

crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ impl<'a> IntoFuture for LoginWithQrCode<'a> {
253253
// ourselves see us as verified and the recovery/backup states will
254254
// be known. If we did receive all the secrets in the secrets
255255
// bundle, then backups will be enabled after this step as well.
256-
self.client.encryption().spawn_initialization_task(None);
256+
self.client.encryption().spawn_initialization_task(None).await;
257257
self.client.encryption().wait_for_e2ee_initialization_tasks().await;
258258

259259
trace!("successfully logged in and enabled E2EE.");

crates/matrix-sdk/src/client/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ impl ClientInner {
414414
let client = Arc::new(client);
415415

416416
#[cfg(feature = "e2e-encryption")]
417-
client.e2ee.initialize_room_key_tasks(&client);
417+
client.e2ee.initialize_tasks(&client);
418418

419419
let _ = client
420420
.event_cache

crates/matrix-sdk/src/encryption/mod.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ use ruma::{
6262
#[cfg(feature = "experimental-send-custom-to-device")]
6363
use ruma::{events::AnyToDeviceEventContent, serde::Raw, to_device::DeviceIdOrAllDevices};
6464
use serde::Deserialize;
65+
use tasks::BundleReceiverTask;
6566
use tokio::sync::{Mutex, RwLockReadGuard};
6667
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
6768
use tracing::{debug, error, instrument, trace, warn};
@@ -134,7 +135,7 @@ impl EncryptionData {
134135
}
135136
}
136137

137-
pub fn initialize_room_key_tasks(&self, client: &Arc<ClientInner>) {
138+
pub fn initialize_tasks(&self, client: &Arc<ClientInner>) {
138139
let weak_client = WeakClient::from_inner(client);
139140

140141
let mut tasks = self.tasks.lock();
@@ -1685,10 +1686,20 @@ impl Encryption {
16851686
/// there is a proposal (MSC3967) to remove this requirement, which would
16861687
/// allow for the initial upload of cross-signing keys without
16871688
/// authentication, rendering this parameter obsolete.
1688-
pub(crate) fn spawn_initialization_task(&self, auth_data: Option<AuthData>) {
1689+
pub(crate) async fn spawn_initialization_task(&self, auth_data: Option<AuthData>) {
1690+
// It's fine to be async here as we're only getting the lock protecting the
1691+
// `OlmMachine`. Since the lock shouldn't be that contested right after logging
1692+
// in we won't delay the login or restoration of the Client.
1693+
let bundle_receiver_task = if self.client.inner.enable_share_history_on_invite {
1694+
Some(BundleReceiverTask::new(&self.client).await)
1695+
} else {
1696+
None
1697+
};
1698+
16891699
let mut tasks = self.client.inner.e2ee.tasks.lock();
16901700

16911701
let this = self.clone();
1702+
16921703
tasks.setup_e2ee = Some(spawn(async move {
16931704
// Update the current state first, so we don't have to wait for the result of
16941705
// network requests
@@ -1707,6 +1718,8 @@ impl Encryption {
17071718
error!("Couldn't setup and resume recovery {e:?}");
17081719
}
17091720
}));
1721+
1722+
tasks.receive_historic_room_key_bundles = bundle_receiver_task;
17101723
}
17111724

17121725
/// Waits for end-to-end encryption initialization tasks to finish, if any

crates/matrix-sdk/src/encryption/tasks.rs

Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,26 @@
1414

1515
use std::{collections::BTreeMap, sync::Arc, time::Duration};
1616

17+
use futures_core::Stream;
18+
use futures_util::{pin_mut, StreamExt};
19+
use matrix_sdk_base::{
20+
crypto::store::types::RoomKeyBundleInfo, InviteAcceptanceDetails, RoomState,
21+
};
1722
use matrix_sdk_common::failures_cache::FailuresCache;
1823
use ruma::{
1924
events::room::encrypted::{EncryptedEventScheme, OriginalSyncRoomEncryptedEvent},
2025
serde::Raw,
2126
OwnedEventId, OwnedRoomId,
2227
};
23-
use tokio::sync::{
24-
mpsc::{self, UnboundedReceiver},
25-
Mutex,
26-
};
27-
use tracing::{debug, trace, warn};
28+
use tokio::sync::{mpsc, Mutex};
29+
use tracing::{debug, info, instrument, trace, warn};
2830

2931
use crate::{
3032
client::WeakClient,
3133
encryption::backups::UploadState,
3234
executor::{spawn, JoinHandle},
33-
Client,
35+
room::shared_room_history,
36+
Client, Room,
3437
};
3538

3639
/// A cache of room keys we already downloaded.
@@ -41,6 +44,7 @@ pub(crate) struct ClientTasks {
4144
pub(crate) upload_room_keys: Option<BackupUploadingTask>,
4245
pub(crate) download_room_keys: Option<BackupDownloadTask>,
4346
pub(crate) update_recovery_state_after_backup: Option<JoinHandle<()>>,
47+
pub(crate) receive_historic_room_key_bundles: Option<BundleReceiverTask>,
4448
pub(crate) setup_e2ee: Option<JoinHandle<()>>,
4549
}
4650

@@ -72,7 +76,7 @@ impl BackupUploadingTask {
7276
let _ = self.sender.send(());
7377
}
7478

75-
pub(crate) async fn listen(client: WeakClient, mut receiver: UnboundedReceiver<()>) {
79+
pub(crate) async fn listen(client: WeakClient, mut receiver: mpsc::UnboundedReceiver<()>) {
7680
while receiver.recv().await.is_some() {
7781
if let Some(client) = client.get() {
7882
let upload_progress = &client.inner.e2ee.backup_state.upload_progress;
@@ -176,7 +180,10 @@ impl BackupDownloadTask {
176180
/// # Arguments
177181
///
178182
/// * `receiver` - The source of incoming [`RoomKeyDownloadRequest`]s.
179-
async fn listen(client: WeakClient, mut receiver: UnboundedReceiver<RoomKeyDownloadRequest>) {
183+
async fn listen(
184+
client: WeakClient,
185+
mut receiver: mpsc::UnboundedReceiver<RoomKeyDownloadRequest>,
186+
) {
180187
let state = Arc::new(Mutex::new(BackupDownloadTaskListenerState::new(client)));
181188

182189
while let Some(room_key_download_request) = receiver.recv().await {
@@ -385,15 +392,97 @@ impl BackupDownloadTaskListenerState {
385392
}
386393
}
387394

395+
pub(crate) struct BundleReceiverTask {
396+
_handle: JoinHandle<()>,
397+
}
398+
399+
impl BundleReceiverTask {
400+
pub async fn new(client: &Client) -> Self {
401+
let stream = client.encryption().historic_room_key_stream().await.expect("E2EE tasks should only be initialized once we have logged in and have access to an OlmMachine");
402+
let weak_client = WeakClient::from_client(client);
403+
let handle = spawn(Self::listen_task(weak_client, stream));
404+
405+
Self { _handle: handle }
406+
}
407+
408+
async fn listen_task(client: WeakClient, stream: impl Stream<Item = RoomKeyBundleInfo>) {
409+
pin_mut!(stream);
410+
411+
// TODO: Listening to this stream is not enough for iOS due to the NSE killing
412+
// our OlmMachine and thus also this stream. We need to add an event handler
413+
// that will listen for the bundle event. To be able to add an event handler,
414+
// we'll have to implement the bundle event in Ruma.
415+
while let Some(bundle_info) = stream.next().await {
416+
let Some(client) = client.get() else {
417+
// The client was dropped while we were waiting on the stream. Let's end the
418+
// loop, since this means that the application has shut down.
419+
break;
420+
};
421+
422+
let Some(room) = client.get_room(&bundle_info.room_id) else {
423+
warn!(room_id = %bundle_info.room_id, "Received a historic room key bundle for an unknown room");
424+
continue;
425+
};
426+
427+
Self::handle_bundle(&room, &bundle_info).await;
428+
}
429+
}
430+
431+
#[instrument(skip(room), fields(room_id = %room.room_id()))]
432+
async fn handle_bundle(room: &Room, bundle_info: &RoomKeyBundleInfo) {
433+
if Self::should_accept_bundle(room, bundle_info) {
434+
info!("Accepting a late key bundle.");
435+
436+
if let Err(e) =
437+
shared_room_history::maybe_accept_key_bundle(room, &bundle_info.sender).await
438+
{
439+
warn!("Couldn't accept a late room key bundle {e:?}");
440+
}
441+
} else {
442+
info!("Refusing to accept a historic room key bundle.");
443+
}
444+
}
445+
446+
fn should_accept_bundle(room: &Room, bundle_info: &RoomKeyBundleInfo) -> bool {
447+
// We accept historic room key bundles up to one day after we have accepted an
448+
// invite.
449+
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
450+
451+
// If we don't have any invite acceptance details, then this client wasn't the
452+
// one that accepted the invite.
453+
let Some(InviteAcceptanceDetails { invite_accepted_at, inviter }) =
454+
room.invite_acceptance_details()
455+
else {
456+
return false;
457+
};
458+
459+
let state = room.state();
460+
let elapsed_since_join = invite_accepted_at.to_system_time().and_then(|t| t.elapsed().ok());
461+
let bundle_sender = &bundle_info.sender;
462+
463+
match (state, elapsed_since_join) {
464+
(RoomState::Joined, Some(elapsed_since_join)) => {
465+
elapsed_since_join < DAY && bundle_sender == &inviter
466+
}
467+
(RoomState::Joined, None) => false,
468+
(RoomState::Left | RoomState::Invited | RoomState::Knocked | RoomState::Banned, _) => {
469+
false
470+
}
471+
}
472+
}
473+
}
474+
388475
#[cfg(all(test, not(target_family = "wasm")))]
389476
mod test {
390-
use matrix_sdk_test::async_test;
391-
use ruma::{event_id, room_id};
477+
use matrix_sdk_test::{
478+
async_test, event_factory::EventFactory, InvitedRoomBuilder, JoinedRoomBuilder,
479+
};
480+
use ruma::{event_id, room_id, user_id};
392481
use serde_json::json;
393482
use wiremock::MockServer;
394483

395484
use super::*;
396-
use crate::test_utils::logged_in_client;
485+
use crate::test_utils::{logged_in_client, mocks::MatrixMockServer};
397486

398487
// Test that, if backups are not enabled, we don't incorrectly mark a room key
399488
// as downloaded.
@@ -451,4 +540,81 @@ mod test {
451540
)
452541
}
453542
}
543+
544+
/// Test that ensures that we only accept a bundle if a certain set of
545+
/// conditions is met.
546+
#[async_test]
547+
async fn test_should_accept_bundle() {
548+
let server = MatrixMockServer::new().await;
549+
550+
let alice_user_id = user_id!("@alice:localhost");
551+
let bob_user_id = user_id!("@bob:localhost");
552+
let joined_room_id = room_id!("!joined:localhost");
553+
let invited_rom_id = room_id!("!invited:localhost");
554+
555+
let client = server
556+
.client_builder()
557+
.logged_in_with_token("ABCD".to_owned(), alice_user_id.into(), "DEVICEID".into())
558+
.build()
559+
.await;
560+
561+
let event_factory = EventFactory::new().room(invited_rom_id);
562+
let bob_member_event = event_factory.member(bob_user_id).into_raw_timeline();
563+
let alice_member_event =
564+
event_factory.member(bob_user_id).invited(alice_user_id).into_raw_timeline();
565+
566+
server
567+
.mock_sync()
568+
.ok_and_run(&client, |builder| {
569+
builder.add_joined_room(JoinedRoomBuilder::new(joined_room_id)).add_invited_room(
570+
InvitedRoomBuilder::new(invited_rom_id)
571+
.add_state_event(bob_member_event.cast())
572+
.add_state_event(alice_member_event.cast()),
573+
);
574+
})
575+
.await;
576+
577+
let room =
578+
client.get_room(joined_room_id).expect("We should have access to our joined room now");
579+
580+
assert!(
581+
room.invite_acceptance_details().is_none(),
582+
"We shouldn't have any invite acceptance details if we didn't join the room on this Client"
583+
);
584+
585+
let bundle_info = RoomKeyBundleInfo {
586+
sender: bob_user_id.to_owned(),
587+
room_id: joined_room_id.to_owned(),
588+
};
589+
590+
assert!(
591+
!BundleReceiverTask::should_accept_bundle(&room, &bundle_info),
592+
"We should not acceept a bundle if we did not join the room from this Client"
593+
);
594+
595+
let invited_room =
596+
client.get_room(invited_rom_id).expect("We should have access to our invited room now");
597+
598+
assert!(
599+
!BundleReceiverTask::should_accept_bundle(&invited_room, &bundle_info),
600+
"We should not accept a bundle if we didn't join the room."
601+
);
602+
603+
server.mock_room_join(invited_rom_id).ok().mock_once().mount().await;
604+
605+
let room = client
606+
.join_room_by_id(invited_rom_id)
607+
.await
608+
.expect("We should be able to join the invited room");
609+
610+
let details = room
611+
.invite_acceptance_details()
612+
.expect("We should have stored the invite acceptance details");
613+
assert_eq!(details.inviter, bob_user_id, "We should have recorded that Bob has invited us");
614+
615+
assert!(
616+
BundleReceiverTask::should_accept_bundle(&room, &bundle_info),
617+
"We should accept a bundle if we just joined the room and did so from this very Client object"
618+
);
619+
}
454620
}

crates/matrix-sdk/src/test_utils/client.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ impl MockClientBuilder {
6363
self
6464
}
6565

66+
/// Enable the share history on invite feature for the Client.
67+
#[cfg(feature = "e2e-encryption")]
68+
pub fn enable_share_history_on_invite(mut self) -> Self {
69+
self.builder = self.builder.with_enable_share_history_on_invite(true);
70+
self
71+
}
72+
73+
/// Use the given encryption settings with the test client.
74+
#[cfg(feature = "e2e-encryption")]
75+
pub fn with_encryption_settings(
76+
mut self,
77+
settings: crate::encryption::EncryptionSettings,
78+
) -> Self {
79+
self.builder = self.builder.with_encryption_settings(settings);
80+
self
81+
}
82+
6683
/// Set the cached server versions in the client.
6784
pub fn server_versions(mut self, versions: Vec<MatrixVersion>) -> Self {
6885
self.server_versions = ServerVersions::Custom(versions);

0 commit comments

Comments
 (0)