diff --git a/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs b/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs index 460a57f3b49..be17f714ad4 100644 --- a/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs +++ b/crates/matrix-sdk-base/src/event_cache/store/integration_tests.rs @@ -48,6 +48,7 @@ pub fn make_test_event(room_id: &RoomId, content: &str) -> TimelineEvent { sender_claimed_keys: Default::default(), }, verification_state: VerificationState::Verified, + session_id: Some("mysessionid9".to_owned()), }; let event = EventFactory::new() diff --git a/crates/matrix-sdk-common/src/deserialized_responses.rs b/crates/matrix-sdk-common/src/deserialized_responses.rs index 33eab867b53..e4a903ab418 100644 --- a/crates/matrix-sdk-common/src/deserialized_responses.rs +++ b/crates/matrix-sdk-common/src/deserialized_responses.rs @@ -302,6 +302,9 @@ pub struct EncryptionInfo { /// Callers that persist this should mark the state as dirty when a device /// change is received down the sync. pub verification_state: VerificationState, + /// The Megolm session ID that was used to encrypt this event, or None if + /// this info was stored before we collected this data. + pub session_id: Option, } /// Represents a matrix room event that has been returned from `/sync`, @@ -540,6 +543,19 @@ impl TimelineEventKind { TimelineEventKind::PlainText { event } => event, } } + + /// The Megolm session ID that was used to send this event, if it was + /// encrypted. + pub fn session_id(&self) -> Option<&str> { + match self { + TimelineEventKind::Decrypted(decrypted_room_event) => { + decrypted_room_event.encryption_info.session_id.as_ref() + } + TimelineEventKind::UnableToDecrypt { utd_info, .. } => utd_info.session_id.as_ref(), + TimelineEventKind::PlainText { .. } => None, + } + .map(String::as_str) + } } #[cfg(not(tarpaulin_include))] @@ -1042,6 +1058,7 @@ mod tests { sender_claimed_keys: Default::default(), }, verification_state: VerificationState::Verified, + session_id: Some("xyz".to_owned()), }, unsigned_encryption_info: Some(BTreeMap::from([( UnsignedEventLocation::RelationsReplace, @@ -1080,6 +1097,7 @@ mod tests { } }, "verification_state": "Verified", + "session_id": "xyz", }, "unsigned_encryption_info": { "RelationsReplace": {"UnableToDecrypt": { @@ -1128,6 +1146,7 @@ mod tests { event.encryption_info().unwrap().algorithm_info, AlgorithmInfo::MegolmV1AesSha2 { .. } ); + assert_eq!(event.encryption_info().unwrap().session_id, None); // Test that the previous format, with an undecryptable unsigned event, can also // be deserialized. @@ -1364,6 +1383,7 @@ mod tests { sender_claimed_keys: Default::default(), }, verification_state: VerificationState::Verified, + session_id: Some("mysessionid76".to_owned()), }; with_settings!({ sort_maps => true, prepend_module_to_snapshot => false }, { @@ -1393,6 +1413,7 @@ mod tests { ]), }, verification_state: VerificationState::Verified, + session_id: Some("mysessionid112".to_owned()), }, unsigned_encryption_info: Some(BTreeMap::from([( UnsignedEventLocation::RelationsThreadLatestEvent, diff --git a/crates/matrix-sdk-common/src/snapshots/snapshot_test_encryption_info.snap b/crates/matrix-sdk-common/src/snapshots/snapshot_test_encryption_info.snap index 26d6c4e1b80..a03b857befd 100644 --- a/crates/matrix-sdk-common/src/snapshots/snapshot_test_encryption_info.snap +++ b/crates/matrix-sdk-common/src/snapshots/snapshot_test_encryption_info.snap @@ -12,5 +12,6 @@ expression: info "sender_claimed_keys": {} } }, - "verification_state": "Verified" + "verification_state": "Verified", + "session_id": "mysessionid76" } diff --git a/crates/matrix-sdk-common/src/snapshots/snapshot_test_sync_timeline_event.snap b/crates/matrix-sdk-common/src/snapshots/snapshot_test_sync_timeline_event.snap index f40882141c1..1acd21cd203 100644 --- a/crates/matrix-sdk-common/src/snapshots/snapshot_test_sync_timeline_event.snap +++ b/crates/matrix-sdk-common/src/snapshots/snapshot_test_sync_timeline_event.snap @@ -17,6 +17,7 @@ expression: "serde_json::to_value(&room_event).unwrap()" }, "sender": "@sender:example.com", "sender_device": "ABCDEFGHIJ", + "session_id": "mysessionid112", "verification_state": "Verified" }, "event": { diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 5567b9a8dea..8014bb2b8a7 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -1671,6 +1671,7 @@ impl OlmMachine { .collect(), }, verification_state, + session_id: Some(session.session_id().to_owned()), }) } diff --git a/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs b/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs index b528216d4fc..e03d13a56f5 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs @@ -14,6 +14,8 @@ use std::{collections::BTreeSet, sync::Arc}; +use imbl::Vector; +use itertools::{Either, Itertools as _}; use matrix_sdk::{ deserialized_responses::TimelineEventKind as SdkTimelineEventKind, executor::JoinHandle, }; @@ -25,8 +27,9 @@ use tracing::{debug, error, field, info, info_span, Instrument as _}; use crate::timeline::{ controller::{TimelineSettings, TimelineState}, + event_item::EventTimelineItemKind, traits::{Decryptor, RoomDataProvider}, - EncryptedMessage, TimelineItem, + EncryptedMessage, EventTimelineItem, TimelineItem, TimelineItemContent, TimelineItemKind, }; /// Holds a long-running task that is used to retry decryption of items in the @@ -119,16 +122,29 @@ async fn decryption_task( } }; - let retry_indices = item_indices_to_retry(state.clone(), &should_retry).await; - if !retry_indices.is_empty() { + // Find the indices of events that are in the supplied sessions, distinguishing + // between UTDs which we need to decrypt, and already-decrypted events where we + // only need to re-fetch encryption info. + let mut state = state.write().await; + let (retry_decryption_indices, retry_info_indices) = + compute_event_indices_to_retry_decryption(&state.items, should_retry); + + // Retry fetching encryption info for events that are already decrypted + if !retry_info_indices.is_empty() { + debug!("Retrying fetching encryption info"); + retry_fetch_encryption_info(&mut state, retry_info_indices, &room_data_provider).await; + } + + // Retry decrypting any unable-to-decrypt messages + if !retry_decryption_indices.is_empty() { debug!("Retrying decryption"); decrypt_by_index( - state.clone(), + &mut state, &request.settings, - room_data_provider.clone(), + &room_data_provider, request.decryptor, should_retry, - retry_indices, + retry_decryption_indices, ) .await } @@ -137,42 +153,109 @@ async fn decryption_task( debug!("Decryption task stopping."); } -/// Return a list of the items within the timeline that we should retry -/// decrypting because their session updated. Items are identified by their -/// index in the supplied `state`'s list of items. -async fn item_indices_to_retry( - state: Arc>, +/// Decide which events should be retried, either for re-decryption, or, if they +/// are already decrypted, for re-checking their encryption info. +/// +/// Returns a tuple `(retry_decryption_indices, retry_info_indices)` where +/// `retry_decryption_indices` is a list of the indices of UTDs to try +/// decrypting, and retry_info_indices is a list of the indices of +/// already-decrypted events whose encryption info we can re-fetch. +fn compute_event_indices_to_retry_decryption( + items: &Vector>, should_retry: impl Fn(&str) -> bool, -) -> Vec { - let state = state.read_owned().await; +) -> (Vec, Vec) { + use Either::{Left, Right}; - state - .items + // We retry an event if its session ID should be retried + let should_retry_event = |event: &EventTimelineItem| { + let session_id = + if let TimelineItemContent::UnableToDecrypt(encrypted_message) = event.content() { + // UTDs carry their session ID inside the content + encrypted_message.session_id() + } else { + // Non-UTDs only have a session ID if they are remote and have it in the + // EncryptionInfo + event + .as_remote() + .and_then(|remote| remote.encryption_info.as_ref()?.session_id.as_ref()) + .map(String::as_str) + }; + + if let Some(session_id) = session_id { + // Should we retry this session ID? + should_retry(session_id) + } else { + // No session ID: don't retry this event + false + } + }; + + items .iter() .enumerate() - .filter_map(|(idx, item)| match item.as_event()?.content().as_unable_to_decrypt()? { - EncryptedMessage::MegolmV1AesSha2 { session_id, .. } if should_retry(session_id) => { - Some(idx) - } - EncryptedMessage::MegolmV1AesSha2 { .. } - | EncryptedMessage::OlmV1Curve25519AesSha2 { .. } - | EncryptedMessage::Unknown => None, + .filter_map(|(idx, item)| { + item.as_event().filter(|e| should_retry_event(e)).map(|event| (idx, event)) }) - .collect() + // Break the result into 2 lists: (utds, decrypted) + .partition_map( + |(idx, event)| { + if event.content().is_unable_to_decrypt() { + Left(idx) + } else { + Right(idx) + } + }, + ) +} + +/// Try to fetch [`EncryptionInfo`] for the events with the supplied +/// indices, and update them where we succeed. +pub(super) async fn retry_fetch_encryption_info( + state: &mut TimelineState, + retry_indices: Vec, + room_data_provider: &P, +) { + for idx in retry_indices { + let old_item = state.items.get(idx); + if let Some(new_item) = make_replacement_for(room_data_provider, old_item).await { + state.items.replace(idx, new_item); + } + } +} + +/// Create a replacement TimelineItem for the supplied one, with new +/// [`EncryptionInfo`] from the supplied `room_data_provider`. Returns None if +/// the supplied item is not a remote event, or if it doesn't have a session ID. +async fn make_replacement_for( + room_data_provider: &P, + item: Option<&Arc>, +) -> Option> { + let item = item?; + let event = item.as_event()?; + let remote = event.as_remote()?; + let session_id = remote.encryption_info.as_ref()?.session_id.as_deref()?; + + let new_encryption_info = + room_data_provider.get_encryption_info(session_id, &event.sender).await; + let mut new_remote = remote.clone(); + new_remote.encryption_info = new_encryption_info; + let new_item = item.with_kind(TimelineItemKind::Event( + event.with_kind(EventTimelineItemKind::Remote(new_remote)), + )); + + Some(new_item) } /// Attempt decryption of the events encrypted with the session IDs in the /// supplied decryption `request`. async fn decrypt_by_index( - state: Arc>, + state: &mut TimelineState, settings: &TimelineSettings, - room_data_provider: impl RoomDataProvider, + room_data_provider: &impl RoomDataProvider, decryptor: D, should_retry: impl Fn(&str) -> bool, retry_indices: Vec, ) { - let mut state = state.clone().write_owned().await; - let push_rules_context = room_data_provider.push_rules_and_context().await; let unable_to_decrypt_hook = state.meta.unable_to_decrypt_hook.clone(); @@ -243,8 +326,228 @@ async fn decrypt_by_index( retry_one, retry_indices, push_rules_context, - &room_data_provider, + room_data_provider, settings, ) .await; } + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, sync::Arc, time::SystemTime}; + + use imbl::{vector, Vector}; + use matrix_sdk::{ + crypto::types::events::UtdCause, + deserialized_responses::{AlgorithmInfo, EncryptionInfo, VerificationState}, + }; + use ruma::{ + events::room::{ + encrypted::{ + EncryptedEventScheme, MegolmV1AesSha2Content, MegolmV1AesSha2ContentInit, + RoomEncryptedEventContent, + }, + message::RoomMessageEventContent, + }, + owned_device_id, owned_event_id, owned_user_id, MilliSecondsSinceUnixEpoch, + OwnedTransactionId, + }; + + use crate::timeline::{ + controller::decryption_retry_task::compute_event_indices_to_retry_decryption, + event_item::{ + EventTimelineItemKind, LocalEventTimelineItem, RemoteEventOrigin, + RemoteEventTimelineItem, + }, + EventSendState, EventTimelineItem, ReactionsByKeyBySender, TimelineDetails, TimelineItem, + TimelineItemContent, TimelineItemKind, TimelineUniqueId, VirtualTimelineItem, + }; + + #[test] + fn test_non_events_are_not_retried() { + // Given a timeline with only non-events + let timeline = vector![TimelineItem::read_marker(), date_divider()]; + // When we ask what to retry + let answer = compute_event_indices_to_retry_decryption(&timeline, always_retry); + // Then we retry nothing + assert!(answer.0.is_empty()); + assert!(answer.1.is_empty()); + } + + #[test] + fn test_non_remote_events_are_not_retried() { + // Given a timeline with only local events + let timeline = vector![local_event()]; + // When we ask what to retry + let answer = compute_event_indices_to_retry_decryption(&timeline, always_retry); + // Then we retry nothing + assert!(answer.0.is_empty()); + assert!(answer.1.is_empty()); + } + + #[test] + fn test_utds_are_retried() { + // Given a timeline with a UTD + let timeline = vector![utd_event("session1")]; + // When we ask what to retry + let answer = compute_event_indices_to_retry_decryption(&timeline, always_retry); + // Then we retry decrypting it, and don't refetch any encryption info + assert_eq!(answer.0, vec![0]); + assert!(answer.1.is_empty()); + } + + #[test] + fn test_remote_decrypted_info_is_refetched() { + // Given a timeline with a decrypted event + let timeline = vector![decrypted_event("session1")]; + // When we ask what to retry + let answer = compute_event_indices_to_retry_decryption(&timeline, always_retry); + // Then we don't need to decrypt anything, but we do refetch the encryption info + assert!(answer.0.is_empty()); + assert_eq!(answer.1, vec![0]); + } + + #[test] + fn test_only_required_sessions_are_retried() { + // Given we want to retry everything in session1 only + + fn retry(s: &str) -> bool { + s == "session1" + } + + // And we have a timeline containing non-events, local events, UTDs and + // decrypted events + let timeline = vector![ + TimelineItem::read_marker(), + utd_event("session1"), + utd_event("session1"), + date_divider(), + utd_event("session2"), + decrypted_event("session1"), + decrypted_event("session1"), + decrypted_event("session2"), + local_event(), + ]; + + // When we ask what to retry + let answer = compute_event_indices_to_retry_decryption(&timeline, retry); + + // Then we re-decrypt the UTDs, and refetch the decrypted events' info + assert_eq!(answer.0, vec![1, 2]); + assert_eq!(answer.1, vec![5, 6]); + } + + fn always_retry(_: &str) -> bool { + true + } + + fn date_divider() -> Arc { + TimelineItem::new( + TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp())), + TimelineUniqueId("datething".to_owned()), + ) + } + + fn local_event() -> Arc { + let event_kind = EventTimelineItemKind::Local(LocalEventTimelineItem { + send_state: EventSendState::NotSentYet, + transaction_id: OwnedTransactionId::from("trans"), + send_handle: None, + }); + + TimelineItem::new( + TimelineItemKind::Event(EventTimelineItem::new( + owned_user_id!("@u:s.to"), + TimelineDetails::Pending, + timestamp(), + TimelineItemContent::RedactedMessage, + event_kind, + true, + )), + TimelineUniqueId("local".to_owned()), + ) + } + + fn utd_event(session_id: &str) -> Arc { + let event_kind = EventTimelineItemKind::Remote(RemoteEventTimelineItem { + event_id: owned_event_id!("$local"), + transaction_id: None, + read_receipts: Default::default(), + is_own: false, + is_highlighted: false, + encryption_info: None, + original_json: None, + latest_edit_json: None, + origin: RemoteEventOrigin::Sync, + }); + + TimelineItem::new( + TimelineItemKind::Event(EventTimelineItem::new( + owned_user_id!("@u:s.to"), + TimelineDetails::Pending, + timestamp(), + TimelineItemContent::unable_to_decrypt( + RoomEncryptedEventContent::new( + EncryptedEventScheme::MegolmV1AesSha2(MegolmV1AesSha2Content::from( + MegolmV1AesSha2ContentInit { + ciphertext: "cyf".to_owned(), + sender_key: "sendk".to_owned(), + device_id: owned_device_id!("DEV"), + session_id: session_id.to_owned(), + }, + )), + None, + ), + UtdCause::Unknown, + ), + event_kind, + true, + )), + TimelineUniqueId("local".to_owned()), + ) + } + + fn decrypted_event(session_id: &str) -> Arc { + let event_kind = EventTimelineItemKind::Remote(RemoteEventTimelineItem { + event_id: owned_event_id!("$local"), + transaction_id: None, + read_receipts: Default::default(), + is_own: false, + is_highlighted: false, + encryption_info: Some(EncryptionInfo { + sender: owned_user_id!("@u:s.co"), + sender_device: None, + algorithm_info: AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key: "".to_owned(), + sender_claimed_keys: BTreeMap::new(), + }, + verification_state: VerificationState::Verified, + session_id: Some(session_id.to_owned()), + }), + original_json: None, + latest_edit_json: None, + origin: RemoteEventOrigin::Sync, + }); + + TimelineItem::new( + TimelineItemKind::Event(EventTimelineItem::new( + owned_user_id!("@u:s.to"), + TimelineDetails::Pending, + timestamp(), + TimelineItemContent::message( + RoomMessageEventContent::text_plain("hi"), + None, + &Vector::new(), + ReactionsByKeyBySender::default(), + ), + event_kind, + true, + )), + TimelineUniqueId("local".to_owned()), + ) + } + + fn timestamp() -> MilliSecondsSinceUnixEpoch { + MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::UNIX_EPOCH).unwrap() + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index 40d39d080be..403d4f2d320 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -325,6 +325,12 @@ impl TimelineItemContent { as_variant!(self, Self::UnableToDecrypt) } + /// Check whether this item's content is a + /// [`UnableToDecrypt`][Self::UnableToDecrypt]. + pub fn is_unable_to_decrypt(&self) -> bool { + matches!(self, Self::UnableToDecrypt(_)) + } + // These constructors could also be `From` implementations, but that would // allow users to call them directly, which should not be supported pub(crate) fn message( @@ -551,6 +557,16 @@ impl EncryptedMessage { _ => Self::Unknown, } } + + /// Return the ID of the Megolm session used to encrypt this message, if it + /// was received via a Megolm session. + pub(crate) fn session_id(&self) -> Option<&str> { + match self { + EncryptedMessage::OlmV1Curve25519AesSha2 { .. } => None, + EncryptedMessage::MegolmV1AesSha2 { session_id, .. } => Some(session_id), + EncryptedMessage::Unknown => None, + } + } } /// An `m.sticker` event. diff --git a/crates/matrix-sdk-ui/src/timeline/tests/edit.rs b/crates/matrix-sdk-ui/src/timeline/tests/edit.rs index 6b17911f1a1..d1d2688b744 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/edit.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/edit.rs @@ -176,6 +176,7 @@ async fn test_edit_updates_encryption_info() { sender_claimed_keys: BTreeMap::new(), }, verification_state: VerificationState::Verified, + session_id: Some("mysessionid6333".to_owned()), }; let original_event: TimelineEvent = DecryptedRoomEvent { diff --git a/crates/matrix-sdk-ui/src/timeline/tests/encryption.rs b/crates/matrix-sdk-ui/src/timeline/tests/encryption.rs index 1d3fd3851ce..cb4df749a70 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/encryption.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/encryption.rs @@ -28,6 +28,9 @@ use eyeball_im::VectorDiff; use matrix_sdk::{ assert_next_matches_with_timeout, crypto::{decrypt_room_key_export, types::events::UtdCause, OlmMachine}, + deserialized_responses::{ + AlgorithmInfo, DecryptedRoomEvent, EncryptionInfo, VerificationLevel, VerificationState, + }, test_utils::test_client_builder, }; use matrix_sdk_base::deserialized_responses::{TimelineEvent, UnableToDecryptReason}; @@ -38,7 +41,7 @@ use ruma::{ EncryptedEventScheme, MegolmV1AesSha2ContentInit, Relation, Replacement, RoomEncryptedEventContent, }, - room_id, + owned_device_id, room_id, serde::Raw, user_id, }; @@ -49,7 +52,8 @@ use tokio::time::sleep; use super::TestTimeline; use crate::{ timeline::{ - tests::TestTimelineBuilder, EncryptedMessage, TimelineDetails, TimelineItemContent, + tests::{TestRoomDataProvider, TestTimelineBuilder}, + EncryptedMessage, TimelineDetails, TimelineItemContent, }, unable_to_decrypt_hook::{UnableToDecryptHook, UnableToDecryptInfo, UtdHookManager}, }; @@ -593,6 +597,100 @@ async fn test_retry_message_decryption_highlighted() { assert!(event.is_highlighted()); } +#[async_test] +async fn test_retry_fetching_encryption_info() { + const SESSION_ID: &str = "C25PoE+4MlNidQD0YU5ibZqHawV0zZ/up7R8vYJBYTY"; + let sender = user_id!("@sender:s.co"); + let room_id = room_id!("!room:s.co"); + + // Given when I ask the room for new encryption info for any session, it will + // say "verified" + let verified_encryption_info = make_encryption_info(SESSION_ID, VerificationState::Verified); + let provider = + TestRoomDataProvider::default().with_encryption_info(SESSION_ID, verified_encryption_info); + let timeline = TestTimelineBuilder::new().provider(provider).build(); + let f = &timeline.factory; + let mut stream = timeline.subscribe_events().await; + + // But right now the timeline contains 2 events whose info says "unverified" + // One is linked to SESSION_ID, the other is linked to some other session. + let timeline_event_this_session = TimelineEvent::from(DecryptedRoomEvent { + event: f.text_msg("foo").sender(sender).room(room_id).into_raw(), + encryption_info: make_encryption_info( + SESSION_ID, + VerificationState::Unverified(VerificationLevel::UnsignedDevice), + ), + unsigned_encryption_info: None, + }); + let timeline_event_other_session = TimelineEvent::from(DecryptedRoomEvent { + event: f.text_msg("foo").sender(sender).room(room_id).into_raw(), + encryption_info: make_encryption_info( + "other_session_id", + VerificationState::Unverified(VerificationLevel::UnsignedDevice), + ), + unsigned_encryption_info: None, + }); + timeline.handle_live_event(timeline_event_this_session).await; + timeline.handle_live_event(timeline_event_other_session).await; + + // Sanity: the events come through as unverified + assert_eq!(timeline.controller.items().await.len(), 3); + { + let event = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + let fetched_encryption_info = event.as_remote().unwrap().encryption_info.as_ref().unwrap(); + assert_matches!( + fetched_encryption_info.verification_state, + VerificationState::Unverified(_) + ); + } + { + let event = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + let fetched_encryption_info = event.as_remote().unwrap().encryption_info.as_ref().unwrap(); + assert_matches!( + fetched_encryption_info.verification_state, + VerificationState::Unverified(_) + ); + } + + // When we retry the session with ID SESSION_ID + let own_user_id = user_id!("@me:s.co"); + let olm_machine = OlmMachine::new(own_user_id, "SomeDeviceId".into()).await; + timeline + .controller + .retry_event_decryption_test( + room_id, + olm_machine, + Some(iter::once(SESSION_ID.to_owned()).collect()), + ) + .await; + + // Then the event in that session has been updated to be verified + let event = + assert_next_matches_with_timeout!(stream, VectorDiff::Set { index: 0, value } => value); + + let fetched_encryption_info = event.as_remote().unwrap().encryption_info.as_ref().unwrap(); + assert_matches!(fetched_encryption_info.verification_state, VerificationState::Verified); + + assert_eq!(timeline.controller.items().await.len(), 3); + + // But the other one is unchanged because it was for a different session - no + // other updates are waiting + assert_pending!(stream); +} + +fn make_encryption_info(session_id: &str, verification_state: VerificationState) -> EncryptionInfo { + EncryptionInfo { + sender: BOB.to_owned(), + sender_device: Some(owned_device_id!("BOBDEVICE")), + algorithm_info: AlgorithmInfo::MegolmV1AesSha2 { + curve25519_key: Default::default(), + sender_claimed_keys: Default::default(), + }, + verification_state, + session_id: Some(session_id.to_owned()), + } +} + #[async_test] async fn test_utd_cause_for_nonmember_event_is_found() { // Given a timline @@ -748,7 +846,7 @@ async fn test_retry_decryption_updates_response() { assert_eq!(reply_details.event_id, original_event_id); let replied_to = as_variant!(&reply_details.event, TimelineDetails::Ready).unwrap(); - assert!(replied_to.content.as_unable_to_decrypt().is_some()); + assert!(replied_to.content.is_unable_to_decrypt()); } // Import a room key backup. diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index 72079f8ea60..9bc9a7b232f 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -29,7 +29,7 @@ use indexmap::IndexMap; use matrix_sdk::{ config::RequestConfig, crypto::OlmMachine, - deserialized_responses::TimelineEvent, + deserialized_responses::{EncryptionInfo, TimelineEvent}, event_cache::paginator::{PaginableRoom, PaginatorError}, room::{EventWithContextResponse, Messages, MessagesOptions}, send_queue::RoomSendQueueUpdate, @@ -268,6 +268,10 @@ struct TestRoomDataProvider { /// Events redacted with that room data providier. pub redacted: Arc>>, + + /// The [`EncryptionInfo`] describing the Megolm sessions that were used to + /// encrypt events. + pub encryption_info: HashMap, } impl TestRoomDataProvider { @@ -279,6 +283,15 @@ impl TestRoomDataProvider { self.fully_read_marker = Some(event_id); self } + + fn with_encryption_info( + mut self, + session_id: &str, + encryption_info: EncryptionInfo, + ) -> TestRoomDataProvider { + self.encryption_info.insert(session_id.to_owned(), encryption_info); + self + } } impl PaginableRoom for TestRoomDataProvider { @@ -415,4 +428,12 @@ impl RoomDataProvider for TestRoomDataProvider { let info = RoomInfo::new(*DEFAULT_TEST_ROOM_ID, RoomState::Joined); SharedObservable::new(info).subscribe() } + + async fn get_encryption_info( + &self, + session_id: &str, + _sender: &UserId, + ) -> Option { + self.encryption_info.get(session_id).cloned() + } } diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index 55cae54db5f..dfb25369a65 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -19,8 +19,10 @@ use indexmap::IndexMap; #[cfg(test)] use matrix_sdk::crypto::{DecryptionSettings, RoomEventDecryptionResult, TrustRequirement}; use matrix_sdk::{ - crypto::types::events::CryptoContextInfo, deserialized_responses::TimelineEvent, - event_cache::paginator::PaginableRoom, AsyncTraitDeps, Result, Room, SendOutsideWasm, + crypto::types::events::CryptoContextInfo, + deserialized_responses::{EncryptionInfo, TimelineEvent}, + event_cache::paginator::PaginableRoom, + AsyncTraitDeps, Result, Room, SendOutsideWasm, }; use matrix_sdk_base::{latest_event::LatestEvent, RoomInfo}; use ruma::{ @@ -121,6 +123,14 @@ pub(super) trait RoomDataProvider: ) -> impl Future> + SendOutsideWasm + 'a; fn room_info(&self) -> Subscriber; + + /// Return the encryption info for the Megolm session with the supplied + /// session ID. + fn get_encryption_info( + &self, + session_id: &str, + sender: &UserId, + ) -> impl Future> + SendOutsideWasm; } impl RoomDataProvider for Room { @@ -273,6 +283,15 @@ impl RoomDataProvider for Room { fn room_info(&self) -> Subscriber { self.subscribe_info() } + + async fn get_encryption_info( + &self, + session_id: &str, + sender: &UserId, + ) -> Option { + // Pass directly on to `Room::get_encryption_info` + self.get_encryption_info(session_id, sender).await + } } // Internal helper to make most of retry_event_decryption independent of a room diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index a4b784e39bb..f3164ba2fbb 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -32,10 +32,13 @@ use futures_util::{ use http::StatusCode; #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] pub use identity_status_changes::IdentityStatusChanges; -#[cfg(feature = "e2e-encryption")] -use matrix_sdk_base::crypto::{DecryptionSettings, RoomEventDecryptionResult}; #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] use matrix_sdk_base::crypto::{IdentityStatusChange, RoomIdentityProvider, UserIdentity}; +#[cfg(feature = "e2e-encryption")] +use matrix_sdk_base::{ + crypto::{DecryptionSettings, RoomEventDecryptionResult}, + deserialized_responses::EncryptionInfo, +}; use matrix_sdk_base::{ deserialized_responses::{ RawAnySyncOrStrippedState, RawSyncOrStrippedState, SyncOrStrippedState, @@ -1308,6 +1311,23 @@ impl Room { Ok(event) } + /// Fetches the [`EncryptionInfo`] for the supplied session_id. + /// + /// This may be used when we receive an update for a session, and we want to + /// reflect the changes in messages we have received that were encrypted + /// with that session, e.g. to remove a warning shield because a device is + /// now verified. + #[cfg(feature = "e2e-encryption")] + pub async fn get_encryption_info( + &self, + session_id: &str, + sender: &UserId, + ) -> Option { + let machine = self.client.olm_machine().await; + let machine = machine.as_ref()?; + machine.get_session_encryption_info(self.room_id(), session_id, sender).await.ok() + } + /// Forces the currently active room key, which is used to encrypt messages, /// to be rotated. /// diff --git a/testing/matrix-sdk-integration-testing/src/tests/sliding_sync/room.rs b/testing/matrix-sdk-integration-testing/src/tests/sliding_sync/room.rs index 12ffc693dcb..9df02816d36 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/sliding_sync/room.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/sliding_sync/room.rs @@ -920,7 +920,7 @@ async fn test_delayed_invite_response_and_sent_message_decryption() { continue; }; - if event.is_utd() { + if event.content().is_unable_to_decrypt() { info!("Observed UTD for {}", event.event_id().unwrap()); } diff --git a/testing/matrix-sdk-integration-testing/src/tests/timeline.rs b/testing/matrix-sdk-integration-testing/src/tests/timeline.rs index 9bb28f4302a..556d779b095 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/timeline.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/timeline.rs @@ -22,6 +22,9 @@ use eyeball_im::{Vector, VectorDiff}; use futures::pin_mut; use futures_util::{FutureExt, StreamExt}; use matrix_sdk::{ + assert_next_with_timeout, + config::SyncSettings, + deserialized_responses::{VerificationLevel, VerificationState}, encryption::{backups::BackupState, EncryptionSettings}, room::edit::EditedContent, ruma::{ @@ -30,15 +33,19 @@ use matrix_sdk::{ room::{encryption::RoomEncryptionEventContent, message::RoomMessageEventContent}, InitialStateEvent, }, - MilliSecondsSinceUnixEpoch, + MilliSecondsSinceUnixEpoch, OwnedEventId, RoomId, UserId, }, - RoomState, + Client, Room, RoomState, }; use matrix_sdk_ui::{ notification_client::NotificationClient, room_list_service::RoomListLoadingState, sync_service::SyncService, - timeline::{EventSendState, ReactionStatus, RoomExt, TimelineItem, TimelineItemContent}, + timeline::{ + EventSendState, EventTimelineItem, ReactionStatus, RoomExt, TimelineItem, + TimelineItemContent, + }, + Timeline, }; use similar_asserts::assert_eq; use stream_assert::assert_pending; @@ -705,3 +712,190 @@ async fn test_room_keys_received_on_notification_client_trigger_redecryption() { assert_eq!(message.body(), "It's a secret to everybody!"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_new_users_first_messages_dont_warn_about_insecure_device_if_it_is_secure() { + async fn timeline_messages(timeline: &Timeline) -> Vec { + timeline + .items() + .await + .iter() + .filter_map(|item| item.as_event()) + .filter(|e| e.content().as_message().is_some()) + .cloned() + .collect() + } + + /// Send the supplied message in the supplied room + async fn send_message(room: &Room, message: &str) -> OwnedEventId { + room.send(RoomMessageEventContent::text_plain(message)) + .await + .expect("We should be able to send a message to our new room") + .event_id + } + + /// Wait for a room with the supplied ID to appear, and then join it and + /// confirm it is encrypted. + async fn join_room(joiner: &Client, room_id: &RoomId) -> Room { + let sync_service = SyncService::builder(joiner.clone()) + .build() + .await + .expect("should be able to create a SyncService"); + + sync_service.start().await; + + let room_list = sync_service + .room_list_service() + .all_rooms() + .await + .expect("should be able to get the RoomList instance"); + + let until_room_list_is_loaded = async { + while let Some(state) = room_list.loading_state().next().await { + if let RoomListLoadingState::Loaded { .. } = state { + break; + } + } + }; + + timeout(Duration::from_secs(5), until_room_list_is_loaded) + .await + .expect("the RoomList should finish loading within 5 seconds"); + + let room = joiner.get_room(room_id).expect("room should be in the room list"); + room.join().await.expect("should be able to join the room"); + + assert_eq!(room.state(), RoomState::Joined); + assert!(room.is_encrypted().await.unwrap()); + + sync_service.stop().await; + + room + } + + /// Invite the supplied `invitee` to the supplied room. `inviter_room` + /// should be a [`Room`] instance created by the inviting user. + async fn invite_to_room(inviter_room: &Room, invitee: &Client) { + inviter_room + .invite_user_by_id(invitee.user_id().expect("client should have an ID")) + .await + .expect("should not fail to invite user to room"); + } + + /// Create a room and ensure it is encrypted + async fn create_encrypted_room(client: &Client) -> Room { + let room = client + .create_room(assign!(CreateRoomRequest::new(), { + is_direct: true, + initial_state: vec![ + InitialStateEvent::new(RoomEncryptionEventContent::with_recommended_defaults()).to_raw_any() + ], + preset: Some(RoomPreset::PrivateChat) + })) + .await + .expect("should not fail to create room"); + + assert!(room + .is_encrypted() + .await + .expect("should be able to check that the room is encrypted")); + + room + } + + /// Hit the `keys/query` endpoint to find the latest user information about + /// the supplied user ID. + async fn fetch_user_identity(client: &Client, user_id: Option<&UserId>) { + client + .encryption() + .request_user_identity(user_id.expect("user_id should not be None")) + .await + .expect("requesting user identity should not fail") + .expect("should be able to see other user's identity"); + } + + /// Create a new [`Client`] that has e2ee tasks done and is cross-signed. + async fn new_cross_signed_client(username: &str) -> Client { + let client = new_client(username).await; + cross_sign(&client).await; + client + } + + /// Create a new [`Client`] that is not yet cross-signed, but has e2ee + /// initialization done. + async fn new_client(username: &str) -> Client { + let client = TestClientBuilder::new(username).use_sqlite().build().await.unwrap(); + client.encryption().wait_for_e2ee_initialization_tasks().await; + client + } + + /// Cross-sign this client's identity, so others will see its devices as + /// verified. + async fn cross_sign(client: &Client) { + client + .encryption() + .bootstrap_cross_signing(None) + .await + .expect("should not fail to bootstrap cross signing for alice"); + } + + // Given two clients who are in an encrypted room, but alice has not yet + // completed cross-signing. + let alice = new_client("alice").await; + let bob = new_cross_signed_client("bob").await; + let room_for_alice = create_encrypted_room(&alice).await; + invite_to_room(&room_for_alice, &bob).await; + let room_for_bob = join_room(&bob, room_for_alice.room_id()).await; + + // We focus on the timeline that gives bob's view of the room + let timeline = room_for_bob.timeline().await.expect("should be able to get a timeline"); + let (_, mut timeline_stream) = timeline.subscribe().await; + + // When alice sends a message in the room and bob syncs it + let event_id = send_message(&room_for_alice, "secret message").await; + // Wait for the event to appear + while timeline.item_by_event_id(&event_id).await.is_none() { + bob.sync_once(SyncSettings::new()).await.expect("should not fail to sync"); + } + assert_next_with_timeout!(timeline_stream); + + { + // Then the message is decrypted but it's not from a verified device + let messages = timeline_messages(&timeline).await; + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content().as_message().unwrap().body(), "secret message"); + assert_eq!( + messages[0].encryption_info().unwrap().verification_state, + VerificationState::Unverified(VerificationLevel::UnsignedDevice) + ); + } + + // But when alice becomes cross-signed and bob finds out about it + cross_sign(&alice).await; + fetch_user_identity(&bob, alice.user_id()).await; + let update2 = assert_next_with_timeout!(timeline_stream); + + { + // Then we updated the timeline to reflect the fact that the message is from a + // verified device. + let messages = timeline_messages(&timeline).await; + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content().as_message().unwrap().body(), "secret message"); + assert_eq!( + messages[0].encryption_info().unwrap().verification_state, + VerificationState::Unverified(VerificationLevel::UnverifiedIdentity) + ); + // (Note: the device is verified, but the _identity_ is not. We're not + // worried about that - it's "pinned".) + } + + { + // Sanity: there is still just one message + assert_eq!(timeline_messages(&timeline).await.len(), 1); + + // And the final update just changed the one item + assert_eq!(update2.len(), 1); + assert_let!(VectorDiff::Set { index, .. } = &update2[0]); + assert_eq!(*index, 10); + } +}