From 853e13ec6dbc87b6221d5597a35e99acd915ab60 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 20 Sep 2024 16:02:11 +0100 Subject: [PATCH 1/5] crypto: Provide DerefMut on OwnUserIdentity and UserIdentity --- crates/matrix-sdk-crypto/src/identities/user.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-crypto/src/identities/user.rs b/crates/matrix-sdk-crypto/src/identities/user.rs index 6a2e0b06faf..44d68c3fb67 100644 --- a/crates/matrix-sdk-crypto/src/identities/user.rs +++ b/crates/matrix-sdk-crypto/src/identities/user.rs @@ -14,7 +14,7 @@ use std::{ collections::HashMap, - ops::Deref, + ops::{Deref, DerefMut}, sync::{ atomic::{AtomicBool, Ordering}, Arc, RwLock, @@ -169,6 +169,12 @@ impl Deref for OwnUserIdentity { } } +impl DerefMut for OwnUserIdentity { + fn deref_mut(&mut self) -> &mut ::Target { + &mut self.inner + } +} + impl OwnUserIdentity { /// Mark our user identity as verified. /// @@ -282,6 +288,12 @@ impl Deref for OtherUserIdentity { } } +impl DerefMut for OtherUserIdentity { + fn deref_mut(&mut self) -> &mut ::Target { + &mut self.inner + } +} + impl OtherUserIdentity { /// Is this user identity verified. pub fn is_verified(&self) -> bool { From 3cc72993bafbbf85b80e2755bfadbb8da0f3a4ee Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 20 Sep 2024 16:03:40 +0100 Subject: [PATCH 2/5] crypto: Allow accessing the underlying identity on a UserIdentity --- crates/matrix-sdk/src/encryption/identities/users.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/matrix-sdk/src/encryption/identities/users.rs b/crates/matrix-sdk/src/encryption/identities/users.rs index aa869d483ba..8663b78e2ce 100644 --- a/crates/matrix-sdk/src/encryption/identities/users.rs +++ b/crates/matrix-sdk/src/encryption/identities/users.rs @@ -108,6 +108,10 @@ impl UserIdentity { Self { inner: identity, client } } + pub(crate) fn underlying_identity(&self) -> CryptoUserIdentities { + self.inner.clone() + } + /// The ID of the user this identity belongs to. /// /// # Examples From b16104c11c8bacc58bc6af7473de09ec6637ee2d Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 20 Sep 2024 16:09:00 +0100 Subject: [PATCH 3/5] crypto: Provide the core logic about how identities change in a room when changes occur --- .../matrix-sdk-crypto/src/identities/mod.rs | 1 + .../src/identities/room_identity_state.rs | 1082 +++++++++++++++++ .../matrix-sdk-crypto/src/identities/user.rs | 12 +- crates/matrix-sdk-crypto/src/lib.rs | 4 + .../src/room/identity_status_changes.rs | 652 ++++++++++ 5 files changed, 1750 insertions(+), 1 deletion(-) create mode 100644 crates/matrix-sdk-crypto/src/identities/room_identity_state.rs create mode 100644 crates/matrix-sdk/src/room/identity_status_changes.rs diff --git a/crates/matrix-sdk-crypto/src/identities/mod.rs b/crates/matrix-sdk-crypto/src/identities/mod.rs index fac4e8e02ac..1a46fb090d5 100644 --- a/crates/matrix-sdk-crypto/src/identities/mod.rs +++ b/crates/matrix-sdk-crypto/src/identities/mod.rs @@ -42,6 +42,7 @@ //! `/keys/query` API call. pub(crate) mod device; pub(crate) mod manager; +pub(crate) mod room_identity_state; pub(crate) mod user; use std::sync::{ diff --git a/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs new file mode 100644 index 00000000000..b7f1185160f --- /dev/null +++ b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs @@ -0,0 +1,1082 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::HashMap; + +use async_trait::async_trait; +use ruma::{ + events::{ + room::member::{MembershipState, SyncRoomMemberEvent}, + SyncStateEvent, + }, + OwnedUserId, UserId, +}; + +use super::UserIdentity; +use crate::store::IdentityUpdates; + +/// Something that can answer questions about the membership of a room and the +/// identities of users. +/// +/// This is implemented by `matrix_sdk::Room` and is a trait here so we can +/// supply a mock when needed. +#[async_trait] +pub trait RoomIdentityProvider: core::fmt::Debug { + /// Is the user with the supplied ID a member of this room? + async fn is_member(&self, user_id: &UserId) -> bool; + + /// Return a list of the [`UserIdentity`] of all members of this room + async fn member_identities(&self) -> Vec; + + /// Return the [`UserIdentity`] of the user with the supplied ID (even if + /// they are not a member of this room) or None if this user does not + /// exist. + async fn user_identity(&self, user_id: &UserId) -> Option; +} + +/// The state of the identities in a given room - whether they are: +/// +/// * in pin violation (the identity changed after we accepted their identity), +/// * verified (we manually did the emoji dance), +/// * previously verified (we did the emoji dance and then their identity +/// changed), +/// * otherwise, they are pinned. +#[derive(Debug)] +pub struct RoomIdentityState { + room: R, + known_states: KnownStates, +} + +impl RoomIdentityState { + /// Create a new RoomIdentityState using the provided room to check whether + /// users are members. + pub async fn new(room: R) -> Self { + let known_states = KnownStates::from_identities(room.member_identities().await); + Self { room, known_states } + } + + /// Provide the current state of the room: a list of all the non-pinned + /// identities and their status. + pub fn current_state(&self) -> Vec { + self.known_states + .known_states + .iter() + .map(|(user_id, state)| IdentityStatusChange { + user_id: user_id.clone(), + changed_to: state.clone(), + }) + .collect() + } + + /// Deal with an incoming event - either someone's identity changed, or some + /// changes happened to a room's membership. + /// + /// Returns the changes (if any) to the list of valid/invalid identities in + /// the room. + pub async fn process_change(&mut self, item: RoomIdentityChange) -> Vec { + match item { + RoomIdentityChange::IdentityUpdates(identity_updates) => { + self.process_identity_changes(identity_updates).await + } + RoomIdentityChange::SyncRoomMemberEvent(sync_room_member_event) => { + self.process_membership_change(sync_room_member_event).await + } + } + } + + async fn process_identity_changes( + &mut self, + identity_updates: IdentityUpdates, + ) -> Vec { + let mut ret = vec![]; + + for user_identity in identity_updates.new.values().chain(identity_updates.changed.values()) + { + // Ignore updates to our own identity + let user_id = user_identity.user_id(); + if self.room.is_member(user_id).await { + let update = self.update_user_state(user_id, user_identity); + if let Some(identity_status_change) = update { + ret.push(identity_status_change); + } + } + } + + ret + } + + async fn process_membership_change( + &mut self, + sync_room_member_event: SyncRoomMemberEvent, + ) -> Vec { + // Ignore redacted events - memberships should come through as new events, not + // redactions. + if let SyncStateEvent::Original(event) = sync_room_member_event { + // Ignore invalid user IDs + let user_id: Result<&UserId, _> = event.state_key.as_str().try_into(); + if let Ok(user_id) = user_id { + // Ignore non-existent users + if let Some(user_identity) = self.room.user_identity(user_id).await { + match event.content.membership { + MembershipState::Join | MembershipState::Invite => { + if let Some(update) = self.update_user_state(user_id, &user_identity) { + return vec![update]; + } + } + MembershipState::Leave | MembershipState::Ban => { + let leaving_state = state_of(&user_identity); + if leaving_state == IdentityState::PinViolation { + // If a user with bad state leaves the room, set them to Pinned, + // which effectively removes them + return vec![self.set_state(user_id, IdentityState::Pinned)]; + } + } + MembershipState::Knock => { + // No need to do anything when someone is knocking + } + _ => {} + } + } + } + } + + // We didn't find a relevant update, so return an empty list + vec![] + } + + fn update_user_state( + &mut self, + user_id: &UserId, + user_identity: &UserIdentity, + ) -> Option { + if let UserIdentity::Other(_) = &user_identity { + // If the user's state has changed + let new_state = state_of(user_identity); + let old_state = self.known_states.get(user_id); + if new_state != old_state { + Some(self.set_state(user_identity.user_id(), new_state)) + } else { + // Nothing changed + None + } + } else { + // Ignore updates to our own identity + None + } + } + + fn set_state(&mut self, user_id: &UserId, new_state: IdentityState) -> IdentityStatusChange { + // Remember the new state of the user + self.known_states.set(user_id, &new_state); + + // And return the update + IdentityStatusChange { user_id: user_id.to_owned(), changed_to: new_state } + } +} + +fn state_of(user_identity: &UserIdentity) -> IdentityState { + if user_identity.is_verified() { + IdentityState::Verified + } else if user_identity.has_verification_violation() { + IdentityState::VerificationViolation + } else if let UserIdentity::Other(u) = user_identity { + if u.identity_needs_user_approval() { + IdentityState::PinViolation + } else { + IdentityState::Pinned + } + } else { + IdentityState::Pinned + } +} + +/// A change in the status of the identity of a member of the room. Returned by +/// [`RoomIdentityState::process_change`] to indicate that something changed in +/// this room and we should either show or hide a warning. +#[derive(Clone, Debug, PartialEq)] +pub struct IdentityStatusChange { + /// The user ID of the user whose identity status changed + pub user_id: OwnedUserId, + + /// The new state of the identity of the user + pub changed_to: IdentityState, +} + +/// The state of an identity - verified, pinned etc. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum IdentityState { + /// The user is verified with us + Verified, + + /// Either this is the first identity we have seen for this user, or the + /// user has acknowledged a change of identity explicitly e.g. by + /// clicking OK on a notification. + Pinned, + + /// The user's identity has changed since it was pinned. The user should be + /// notified about this and given the opportunity to acknowledge the + /// change, which will make the new identity pinned. + /// When the user acknowledges the change, the app should call + /// [`crate::OtherUserIdentity::pin_current_master_key`]. + PinViolation, + + /// The user's identity has changed, and before that it was verified. This + /// is a serious problem. The user can either verify again to make this + /// identity verified, or withdraw verification + /// [`UserIdentity::withdraw_verification`] to make it pinned. + VerificationViolation, +} + +/// The type of update that can be received by +/// [`RoomIdentityState::process_change`] - either a change of someone's +/// identity, or a change of room membership. +#[derive(Debug)] +pub enum RoomIdentityChange { + /// Someone's identity changed + IdentityUpdates(IdentityUpdates), + + /// Someone joined or left a room + SyncRoomMemberEvent(SyncRoomMemberEvent), +} + +/// What we know about the states of users in this room. +/// Only stores users who _not_ in the Pinned stated. +#[derive(Debug)] +struct KnownStates { + known_states: HashMap, +} + +impl KnownStates { + fn from_identities(member_identities: impl IntoIterator) -> Self { + let mut known_states = HashMap::new(); + for user_identity in member_identities { + let state = state_of(&user_identity); + if state != IdentityState::Pinned { + known_states.insert(user_identity.user_id().to_owned(), state); + } + } + Self { known_states } + } + + /// Return the known state of the supplied user, or IdentityState::Pinned if + /// we don't know. + fn get(&self, user_id: &UserId) -> IdentityState { + self.known_states.get(user_id).cloned().unwrap_or(IdentityState::Pinned) + } + + /// Set the supplied user's state to the state given. If identity_state is + /// IdentityState::Pinned, forget this user. + fn set(&mut self, user_id: &UserId, identity_state: &IdentityState) { + if let IdentityState::Pinned = identity_state { + self.known_states.remove(user_id); + } else { + self.known_states.insert(user_id.to_owned(), identity_state.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use matrix_sdk_test::async_test; + use ruma::{ + device_id, + events::{ + room::member::{ + MembershipState, RoomMemberEventContent, RoomMemberUnsigned, SyncRoomMemberEvent, + }, + OriginalSyncStateEvent, + }, + owned_event_id, owned_user_id, user_id, MilliSecondsSinceUnixEpoch, OwnedUserId, UInt, + UserId, + }; + use tokio::sync::Mutex; + + use super::{IdentityState, RoomIdentityChange, RoomIdentityProvider, RoomIdentityState}; + use crate::{ + identities::user::testing::own_identity_wrapped, + olm::PrivateCrossSigningIdentity, + store::{IdentityUpdates, Store}, + Account, IdentityStatusChange, OtherUserIdentity, OtherUserIdentityData, OwnUserIdentity, + OwnUserIdentityData, UserIdentity, + }; + + #[async_test] + async fn test_unpinning_a_pinned_identity_in_the_room_notifies() { + // Given someone in the room is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When their identity changes to unpinned + let updates = identity_change(user_id, IdentityState::PinViolation, false, false).await; + let update = state.process_change(updates).await; + + // Then we emit an update saying they became unpinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::PinViolation + }] + ); + } + + #[async_test] + async fn test_pinning_an_unpinned_identity_in_the_room_notifies() { + // Given someone in the room is unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When their identity changes to pinned + let updates = identity_change(user_id, IdentityState::Pinned, false, false).await; + let update = state.process_change(updates).await; + + // Then we emit an update saying they became pinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::Pinned + }] + ); + } + + #[async_test] + async fn test_unpinning_an_identity_not_in_the_room_does_nothing() { + // Given an empty room + let user_id = user_id!("@u:s.co"); + let room = FakeRoom::new(); + let mut state = RoomIdentityState::new(room).await; + + // When a new unpinned user identity appears but they are not in the room + let updates = identity_change(user_id, IdentityState::PinViolation, true, false).await; + let update = state.process_change(updates).await; + + // Then we emit no update + assert_eq!(update, vec![]); + } + + #[async_test] + async fn test_pinning_an_identity_not_in_the_room_does_nothing() { + // Given an empty room + let user_id = user_id!("@u:s.co"); + let room = FakeRoom::new(); + let mut state = RoomIdentityState::new(room).await; + + // When a new pinned user appears but is not in the room + let updates = identity_change(user_id, IdentityState::Pinned, true, false).await; + let update = state.process_change(updates).await; + + // Then we emit no update + assert_eq!(update, []); + } + + #[async_test] + async fn test_pinning_an_already_pinned_identity_in_the_room_does_nothing() { + // Given someone in the room is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When we are told they are pinned + let updates = identity_change(user_id, IdentityState::Pinned, false, false).await; + let update = state.process_change(updates).await; + + // Then we emit no update + assert_eq!(update, []); + } + + #[async_test] + async fn test_unpinning_an_already_unpinned_identity_in_the_room_does_nothing() { + // Given someone in the room is unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When we are told they are unpinned + let updates = identity_change(user_id, IdentityState::PinViolation, false, false).await; + let update = state.process_change(updates).await; + + // Then we emit no update + assert_eq!(update, []); + } + + #[async_test] + async fn test_a_pinned_identity_joining_the_room_does_nothing() { + // Given an empty room and we know of a user who is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When the pinned user joins the room + let updates = room_change(user_id, MembershipState::Join); + let update = state.process_change(updates).await; + + // Then we emit no update because they are pinned + assert_eq!(update, []); + } + + #[async_test] + async fn test_an_unpinned_identity_joining_the_room_notifies() { + // Given an empty room and we know of a user who is unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When the unpinned user joins the room + let updates = room_change(user_id, MembershipState::Join); + let update = state.process_change(updates).await; + + // Then we emit an update saying they became unpinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::PinViolation + }] + ); + } + + #[async_test] + async fn test_a_pinned_identity_invited_to_the_room_does_nothing() { + // Given an empty room and we know of a user who is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When the pinned user is invited to the room + let updates = room_change(user_id, MembershipState::Invite); + let update = state.process_change(updates).await; + + // Then we emit no update because they are pinned + assert_eq!(update, []); + } + + #[async_test] + async fn test_an_unpinned_identity_invited_to_the_room_notifies() { + // Given an empty room and we know of a user who is unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When the unpinned user is invited to the room + let updates = room_change(user_id, MembershipState::Invite); + let update = state.process_change(updates).await; + + // Then we emit an update saying they became unpinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::PinViolation + }] + ); + } + + #[async_test] + async fn test_own_identity_becoming_unpinned_is_ignored() { + // Given I am pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(own_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When I become unpinned + let updates = identity_change(user_id, IdentityState::PinViolation, false, true).await; + let update = state.process_change(updates).await; + + // Then we do nothing because own identities are ignored + assert_eq!(update, vec![]); + } + + #[async_test] + async fn test_own_identity_becoming_pinned_is_ignored() { + // Given I am unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(own_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When I become unpinned + let updates = identity_change(user_id, IdentityState::Pinned, false, true).await; + let update = state.process_change(updates).await; + + // Then we do nothing because own identities are ignored + assert_eq!(update, vec![]); + } + + #[async_test] + async fn test_own_pinned_identity_joining_room_is_ignored() { + // Given an empty room and we know of a user who is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(own_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When the pinned user joins the room + let updates = room_change(user_id, MembershipState::Join); + let update = state.process_change(updates).await; + + // Then we emit no update because this is our own identity + assert_eq!(update, []); + } + + #[async_test] + async fn test_own_unpinned_identity_joining_room_is_ignored() { + // Given an empty room and we know of a user who is unpinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.non_member(own_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When the unpinned user joins the room + let updates = room_change(user_id, MembershipState::Join); + let update = state.process_change(updates).await; + + // Then we emit no update because this is our own identity + assert_eq!(update, vec![]); + } + + #[async_test] + async fn test_a_pinned_identity_leaving_the_room_does_nothing() { + // Given a pinned user is in the room + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When the pinned user leaves the room + let updates = room_change(user_id, MembershipState::Leave); + let update = state.process_change(updates).await; + + // Then we emit no update because they are pinned + assert_eq!(update, []); + } + + #[async_test] + async fn test_an_unpinned_identity_leaving_the_room_notifies() { + // Given an unpinned user is in the room + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When the unpinned user leaves the room + let updates = room_change(user_id, MembershipState::Leave); + let update = state.process_change(updates).await; + + // Then we emit an update saying they became unpinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::Pinned + }] + ); + } + + #[async_test] + async fn test_a_pinned_identity_being_banned_does_nothing() { + // Given a pinned user is in the room + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When the pinned user is banned + let updates = room_change(user_id, MembershipState::Ban); + let update = state.process_change(updates).await; + + // Then we emit no update because they are pinned + assert_eq!(update, []); + } + + #[async_test] + async fn test_an_unpinned_identity_being_banned_notifies() { + // Given an unpinned user is in the room + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await; + + // When the unpinned user is banned + let updates = room_change(user_id, MembershipState::Ban); + let update = state.process_change(updates).await; + + // Then we emit an update saying they became unpinned + assert_eq!( + update, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::Pinned + }] + ); + } + + #[async_test] + async fn test_multiple_simultaneous_identity_updates_are_all_notified() { + // Given several people in the room with different states + let user1 = user_id!("@u1:s.co"); + let user2 = user_id!("@u2:s.co"); + let user3 = user_id!("@u3:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user1, IdentityState::Pinned).await); + room.member(other_user_identities(user2, IdentityState::PinViolation).await); + room.member(other_user_identities(user3, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When they all change state simultaneously + let updates = identity_changes(&[ + IdentityChangeSpec { + user_id: user1.to_owned(), + changed_to: IdentityState::PinViolation, + new: false, + own: false, + }, + IdentityChangeSpec { + user_id: user2.to_owned(), + changed_to: IdentityState::Pinned, + new: false, + own: false, + }, + IdentityChangeSpec { + user_id: user3.to_owned(), + changed_to: IdentityState::PinViolation, + new: false, + own: false, + }, + ]) + .await; + let update = state.process_change(updates).await; + + // Then we emit updates for each of them + assert_eq!( + update, + vec![ + IdentityStatusChange { + user_id: user1.to_owned(), + changed_to: IdentityState::PinViolation + }, + IdentityStatusChange { + user_id: user2.to_owned(), + changed_to: IdentityState::Pinned + }, + IdentityStatusChange { + user_id: user3.to_owned(), + changed_to: IdentityState::PinViolation + } + ] + ); + } + + #[async_test] + async fn test_multiple_changes_are_notified() { + // Given someone in the room is pinned + let user_id = user_id!("@u:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user_id, IdentityState::Pinned).await); + let mut state = RoomIdentityState::new(room).await; + + // When they change state multiple times + let update1 = state + .process_change( + identity_change(user_id, IdentityState::PinViolation, false, false).await, + ) + .await; + let update2 = state + .process_change( + identity_change(user_id, IdentityState::PinViolation, false, false).await, + ) + .await; + let update3 = state + .process_change(identity_change(user_id, IdentityState::Pinned, false, false).await) + .await; + let update4 = state + .process_change( + identity_change(user_id, IdentityState::PinViolation, false, false).await, + ) + .await; + + // Then we emit updates each time + assert_eq!( + update1, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::PinViolation + }] + ); + // (Except update2 where nothing changed) + assert_eq!(update2, vec![]); + assert_eq!( + update3, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::Pinned + }] + ); + assert_eq!( + update4, + vec![IdentityStatusChange { + user_id: user_id.to_owned(), + changed_to: IdentityState::PinViolation + }] + ); + } + + #[async_test] + async fn test_current_state_of_all_pinned_room_is_empty() { + // Given everyone in the room is pinned + let user1 = user_id!("@u1:s.co"); + let user2 = user_id!("@u2:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user1, IdentityState::Pinned).await); + room.member(other_user_identities(user2, IdentityState::Pinned).await); + let state = RoomIdentityState::new(room).await; + assert!(state.current_state().is_empty()); + } + + #[async_test] + async fn test_current_state_contains_all_unpinned_users() { + // Given some people are unpinned + let user1 = user_id!("@u1:s.co"); + let user2 = user_id!("@u2:s.co"); + let user3 = user_id!("@u3:s.co"); + let user4 = user_id!("@u4:s.co"); + let mut room = FakeRoom::new(); + room.member(other_user_identities(user1, IdentityState::Pinned).await); + room.member(other_user_identities(user2, IdentityState::PinViolation).await); + room.member(other_user_identities(user3, IdentityState::Pinned).await); + room.member(other_user_identities(user4, IdentityState::PinViolation).await); + let mut state = RoomIdentityState::new(room).await.current_state(); + state.sort_by_key(|change| change.user_id.to_owned()); + assert_eq!( + state, + vec![ + IdentityStatusChange { + user_id: owned_user_id!("@u2:s.co"), + changed_to: IdentityState::PinViolation + }, + IdentityStatusChange { + user_id: owned_user_id!("@u4:s.co"), + changed_to: IdentityState::PinViolation + } + ] + ); + } + + #[derive(Debug)] + struct FakeRoom { + members: Vec, + non_members: Vec, + } + + impl FakeRoom { + fn new() -> Self { + Self { members: Default::default(), non_members: Default::default() } + } + + fn member(&mut self, user_identity: UserIdentity) { + self.members.push(user_identity); + } + + fn non_member(&mut self, user_identity: UserIdentity) { + self.non_members.push(user_identity); + } + } + + #[async_trait] + impl RoomIdentityProvider for FakeRoom { + async fn is_member(&self, user_id: &UserId) -> bool { + self.members.iter().any(|u| u.user_id() == user_id) + } + + async fn member_identities(&self) -> Vec { + self.members.clone() + } + + async fn user_identity(&self, user_id: &UserId) -> Option { + self.non_members + .iter() + .chain(self.members.iter()) + .find(|u| u.user_id() == user_id) + .cloned() + } + } + + fn room_change(user_id: &UserId, new_state: MembershipState) -> RoomIdentityChange { + let event = SyncRoomMemberEvent::Original(OriginalSyncStateEvent { + content: RoomMemberEventContent::new(new_state), + event_id: owned_event_id!("$1"), + sender: owned_user_id!("@admin:b.c"), + origin_server_ts: MilliSecondsSinceUnixEpoch(UInt::new(2123).unwrap()), + unsigned: RoomMemberUnsigned::new(), + state_key: user_id.to_owned(), + }); + RoomIdentityChange::SyncRoomMemberEvent(event) + } + + async fn identity_change( + user_id: &UserId, + changed_to: IdentityState, + new: bool, + own: bool, + ) -> RoomIdentityChange { + identity_changes(&[IdentityChangeSpec { + user_id: user_id.to_owned(), + changed_to, + new, + own, + }]) + .await + } + + struct IdentityChangeSpec { + user_id: OwnedUserId, + changed_to: IdentityState, + new: bool, + own: bool, + } + + async fn identity_changes(changes: &[IdentityChangeSpec]) -> RoomIdentityChange { + let mut updates = IdentityUpdates::default(); + + for change in changes { + let user_identities = if change.own { + let user_identity = + own_user_identity(&change.user_id, change.changed_to.clone()).await; + UserIdentity::Own(user_identity) + } else { + let user_identity = + other_user_identity(&change.user_id, change.changed_to.clone()).await; + UserIdentity::Other(user_identity) + }; + + if change.new { + updates.new.insert(user_identities.user_id().to_owned(), user_identities); + } else { + updates.changed.insert(user_identities.user_id().to_owned(), user_identities); + } + } + RoomIdentityChange::IdentityUpdates(updates) + } + + /// Create an other `UserIdentity` + async fn other_user_identities( + user_id: &UserId, + identity_state: IdentityState, + ) -> UserIdentity { + UserIdentity::Other(other_user_identity(user_id, identity_state).await) + } + + /// Create an other `UserIdentity` for use in tests + async fn other_user_identity( + user_id: &UserId, + identity_state: IdentityState, + ) -> OtherUserIdentity { + use std::sync::Arc; + + use ruma::owned_device_id; + use tokio::sync::Mutex; + + use crate::{ + olm::PrivateCrossSigningIdentity, + store::{CryptoStoreWrapper, MemoryStore}, + verification::VerificationMachine, + Account, + }; + + let device_id = owned_device_id!("DEV123"); + let account = Account::with_device_id(user_id, &device_id); + + let private_identity = + Arc::new(Mutex::new(PrivateCrossSigningIdentity::with_account(&account).await.0)); + + let other_user_identity_data = + OtherUserIdentityData::from_private(&*private_identity.lock().await).await; + + let mut user_identity = OtherUserIdentity { + inner: other_user_identity_data, + own_identity: None, + verification_machine: VerificationMachine::new( + account.clone(), + Arc::new(Mutex::new(PrivateCrossSigningIdentity::new( + account.user_id().to_owned(), + ))), + Arc::new(CryptoStoreWrapper::new( + account.user_id(), + account.device_id(), + MemoryStore::new(), + )), + ), + }; + + match identity_state { + IdentityState::Verified => { + // TODO + assert!(user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + assert!(!user_identity.identity_needs_user_approval()); + } + IdentityState::Pinned => { + // Pinned is the default state + assert!(!user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + assert!(!user_identity.identity_needs_user_approval()); + } + IdentityState::PinViolation => { + change_master_key(&mut user_identity, &account).await; + assert!(!user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + assert!(user_identity.identity_needs_user_approval()); + } + IdentityState::VerificationViolation => { + // TODO + assert!(!user_identity.is_verified()); + assert!(user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + assert!(user_identity.identity_needs_user_approval()); + } + } + + user_identity + } + + /// Create an own `UserIdentity` + async fn own_user_identities(user_id: &UserId, identity_state: IdentityState) -> UserIdentity { + UserIdentity::Own(own_user_identity(user_id, identity_state).await) + } + + /// Create an own `UserIdentity` for use in tests + async fn own_user_identity(user_id: &UserId, identity_state: IdentityState) -> OwnUserIdentity { + use std::sync::Arc; + + use ruma::owned_device_id; + use tokio::sync::Mutex; + + use crate::{ + olm::PrivateCrossSigningIdentity, + store::{CryptoStoreWrapper, MemoryStore}, + verification::VerificationMachine, + Account, + }; + + let device_id = owned_device_id!("DEV123"); + let account = Account::with_device_id(user_id, &device_id); + + let private_identity = + Arc::new(Mutex::new(PrivateCrossSigningIdentity::with_account(&account).await.0)); + + let own_user_identity_data = + OwnUserIdentityData::from_private(&*private_identity.lock().await).await; + + let cross_signing_identity = PrivateCrossSigningIdentity::new(account.user_id().to_owned()); + let verification_machine = VerificationMachine::new( + account.clone(), + Arc::new(Mutex::new(cross_signing_identity.clone())), + Arc::new(CryptoStoreWrapper::new( + account.user_id(), + account.device_id(), + MemoryStore::new(), + )), + ); + + let mut user_identity = own_identity_wrapped( + own_user_identity_data, + verification_machine.clone(), + Store::new( + account.static_data().clone(), + Arc::new(Mutex::new(cross_signing_identity)), + Arc::new(CryptoStoreWrapper::new( + user_id!("@u:s.co"), + device_id!("DEV7"), + MemoryStore::new(), + )), + verification_machine, + ), + ); + + match identity_state { + IdentityState::Verified => { + // TODO + assert!(user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + } + IdentityState::Pinned => { + // Pinned is the default state + assert!(!user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + } + IdentityState::PinViolation => { + change_own_master_key(&mut user_identity, &account).await; + assert!(!user_identity.is_verified()); + assert!(!user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + } + IdentityState::VerificationViolation => { + // TODO + assert!(!user_identity.is_verified()); + assert!(user_identity.was_previously_verified()); + assert!(!user_identity.has_verification_violation()); + } + } + + user_identity + } + + async fn change_master_key(user_identity: &mut OtherUserIdentity, account: &Account) { + // Create a new master key and self signing key + let private_identity = + Arc::new(Mutex::new(PrivateCrossSigningIdentity::with_account(account).await.0)); + let data = OtherUserIdentityData::from_private(&*private_identity.lock().await).await; + + // And set them on the existing identity + user_identity + .update(data.master_key().clone(), data.self_signing_key().clone(), None) + .unwrap(); + } + + async fn change_own_master_key(user_identity: &mut OwnUserIdentity, account: &Account) { + // Create a new master key and self signing key + let private_identity = + Arc::new(Mutex::new(PrivateCrossSigningIdentity::with_account(account).await.0)); + let data = OwnUserIdentityData::from_private(&*private_identity.lock().await).await; + + // And set them on the existing identity + user_identity + .update( + data.master_key().clone(), + data.self_signing_key().clone(), + data.user_signing_key().clone(), + ) + .unwrap(); + } +} diff --git a/crates/matrix-sdk-crypto/src/identities/user.rs b/crates/matrix-sdk-crypto/src/identities/user.rs index 44d68c3fb67..27fa450e811 100644 --- a/crates/matrix-sdk-crypto/src/identities/user.rs +++ b/crates/matrix-sdk-crypto/src/identities/user.rs @@ -1152,7 +1152,7 @@ where pub(crate) mod testing { use ruma::{api::client::keys::get_keys::v3::Response as KeyQueryResponse, user_id}; - use super::{OtherUserIdentityData, OwnUserIdentityData}; + use super::{OtherUserIdentityData, OwnUserIdentity, OwnUserIdentityData}; #[cfg(test)] use crate::{identities::manager::testing::other_user_id, olm::PrivateCrossSigningIdentity}; use crate::{ @@ -1160,7 +1160,9 @@ pub(crate) mod testing { manager::testing::{other_key_query, own_key_query}, DeviceData, }, + store::Store, types::CrossSigningKey, + verification::VerificationMachine, }; /// Generate test devices from KeyQueryResponse @@ -1197,6 +1199,14 @@ pub(crate) mod testing { own_identity(&own_key_query()) } + pub fn own_identity_wrapped( + inner: OwnUserIdentityData, + verification_machine: VerificationMachine, + store: Store, + ) -> OwnUserIdentity { + OwnUserIdentity { inner, verification_machine, store } + } + /// Generate default other "own" identity for tests #[cfg(test)] pub async fn get_other_own_identity() -> OwnUserIdentityData { diff --git a/crates/matrix-sdk-crypto/src/lib.rs b/crates/matrix-sdk-crypto/src/lib.rs index afe19c12e35..d136a2f6bdf 100644 --- a/crates/matrix-sdk-crypto/src/lib.rs +++ b/crates/matrix-sdk-crypto/src/lib.rs @@ -44,6 +44,10 @@ pub mod testing { use std::collections::{BTreeMap, BTreeSet}; +pub use identities::room_identity_state::{ + IdentityState, IdentityStatusChange, RoomIdentityChange, RoomIdentityProvider, + RoomIdentityState, +}; use ruma::OwnedRoomId; /// Return type for the room key importing. diff --git a/crates/matrix-sdk/src/room/identity_status_changes.rs b/crates/matrix-sdk/src/room/identity_status_changes.rs new file mode 100644 index 00000000000..8185648f09c --- /dev/null +++ b/crates/matrix-sdk/src/room/identity_status_changes.rs @@ -0,0 +1,652 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Facility to track changes to the identity of members of rooms. + +use std::collections::BTreeMap; + +use async_stream::stream; +use futures_core::Stream; +use futures_util::{stream_select, StreamExt}; +use matrix_sdk_base::crypto::{IdentityStatusChange, RoomIdentityChange, RoomIdentityState}; +use ruma::{events::room::member::SyncRoomMemberEvent, OwnedUserId, UserId}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use super::Room; +use crate::{ + encryption::identities::{IdentityUpdates, UserIdentity}, + event_handler::EventHandlerDropGuard, + Client, Error, Result, +}; + +/// Support for creating a stream of batches of [`IdentityStatusChange`]. +/// +/// Internally, this subscribes to all identity changes, and to room events that +/// change the membership, and provides a stream of all changes to the identity +/// status of all room members. +/// +/// This struct does not represent the actual stream, but the state that is used +/// to produce the values of the stream. +/// +/// It does provide a method to create the stream: +/// [`IdentityStatusChanges::create_stream`]. +#[derive(Debug)] +pub struct IdentityStatusChanges { + /// Who is in the room and who is in identity violation at this moment + room_identity_state: RoomIdentityState, + + /// Dropped when we are dropped, and unregisters the event handler we + /// registered to listen for room events + _drop_guard: EventHandlerDropGuard, +} + +impl IdentityStatusChanges { + /// Create a new stream of changes to the identity status of members of a + /// room. + /// + /// The "status" of an identity changes when our level of trust in it + /// changes. + /// + /// For example, if an identity is "pinned" i.e. not manually verified, but + /// known, and it becomes a "unpinned" i.e. unknown, because the + /// encryption keys are different and the user has not acknowledged + /// this, then this constitues a status change. Also, if an identity is + /// "unpinned" and becomes "pinned", this is also a status change. + /// + /// The supplied stream is intended to provide enough information for a + /// client to display a list of room members whose identities have + /// changed, and allow the user to acknowledge this or act upon it. + /// + /// Note: when an unpinned user leaves a room, an update is generated + /// stating that they have become pinned, even though they may not + /// necessarily have become pinned, but we don't care any more because they + /// left the room. + pub async fn create_stream( + room: Room, + ) -> Result>> { + let identity_updates = wrap_identity_updates(&room.client).await?; + let (drop_guard, room_member_events) = wrap_room_member_events(&room); + let mut unprocessed_stream = combine_streams(identity_updates, room_member_events); + let own_user_id = room.client.user_id().ok_or(Error::InsufficientData)?.to_owned(); + + let mut state = IdentityStatusChanges { + room_identity_state: RoomIdentityState::new(room).await, + _drop_guard: drop_guard, + }; + + Ok(stream!({ + let current_state = + filter_non_self(state.room_identity_state.current_state(), &own_user_id); + if !current_state.is_empty() { + yield current_state; + } + while let Some(item) = unprocessed_stream.next().await { + let update = filter_non_self( + state.room_identity_state.process_change(item).await, + &own_user_id, + ); + if !update.is_empty() { + yield update; + } + } + })) + } +} + +fn filter_non_self( + mut input: Vec, + own_user_id: &UserId, +) -> Vec { + input.retain(|change| change.user_id != own_user_id); + input +} + +fn combine_streams( + identity_updates: impl Stream + Unpin, + room_member_events: impl Stream + Unpin, +) -> impl Stream { + stream_select!(identity_updates, room_member_events) +} + +async fn wrap_identity_updates(client: &Client) -> Result> { + Ok(client + .encryption() + .user_identities_stream() + .await? + .map(|item| RoomIdentityChange::IdentityUpdates(to_base_updates(item)))) +} + +fn to_base_updates(input: IdentityUpdates) -> matrix_sdk_base::crypto::store::IdentityUpdates { + matrix_sdk_base::crypto::store::IdentityUpdates { + new: to_base_identities(input.new), + changed: to_base_identities(input.changed), + unchanged: Default::default(), + } +} + +fn to_base_identities( + input: BTreeMap, +) -> BTreeMap { + input.into_iter().map(|(k, v)| (k, v.underlying_identity())).collect() +} + +fn wrap_room_member_events( + room: &Room, +) -> (EventHandlerDropGuard, impl Stream) { + let own_user_id = room.own_user_id().to_owned(); + let room_id = room.room_id(); + let (sender, receiver) = mpsc::channel(16); + let handle = + room.client.add_room_event_handler(room_id, move |event: SyncRoomMemberEvent| async move { + if *event.state_key() == own_user_id { + return; + } + let _: Result<_, _> = sender.send(RoomIdentityChange::SyncRoomMemberEvent(event)).await; + }); + let drop_guard = room.client.event_handler_drop_guard(handle); + (drop_guard, ReceiverStream::new(receiver)) +} + +#[cfg(test)] +mod tests { + use std::{ + pin::{pin, Pin}, + time::Duration, + }; + + use futures_core::Stream; + use futures_util::FutureExt; + use matrix_sdk_base::crypto::{IdentityState, IdentityStatusChange}; + use matrix_sdk_test::async_test; + use test_setup::TestSetup; + use tokio_stream::{StreamExt, Timeout}; + + #[async_test] + async fn test_when_user_becomes_unpinned_we_report_it() { + // Given a room containing us and Bob + let t = TestSetup::new_room_with_other_member().await; + + // And Bob's identity is pinned + t.pin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + + // When Bob becomes unpinned + t.unpin().await; + + // Then we were notified about it + let change = next_change(&mut pin!(changes)).await; + assert_eq!(change[0].user_id, t.user_id()); + assert_eq!(change[0].changed_to, IdentityState::PinViolation); + assert_eq!(change.len(), 1); + } + + #[async_test] + async fn test_when_user_becomes_pinned_we_report_it() { + // Given a room containing us and Bob + let t = TestSetup::new_room_with_other_member().await; + + // And Bob's identity is unpinned + t.unpin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + let mut changes = pin!(changes); + + // When Bob becomes pinned + t.pin().await; + + // Then we were notified about the initial state of the room + let change1 = next_change(&mut changes).await; + assert_eq!(change1[0].user_id, t.user_id()); + assert_eq!(change1[0].changed_to, IdentityState::PinViolation); + assert_eq!(change1.len(), 1); + + // And the change when Bob became pinned + let change2 = next_change(&mut changes).await; + assert_eq!(change2[0].user_id, t.user_id()); + assert_eq!(change2[0].changed_to, IdentityState::Pinned); + assert_eq!(change2.len(), 1); + } + + #[async_test] + async fn test_when_an_unpinned_user_joins_we_report_it() { + // Given a room containing just us + let mut t = TestSetup::new_just_me_room().await; + + // And Bob's identity is unpinned + t.unpin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + + // When Bob joins the room + t.join().await; + + // Then we were notified about it + let change = next_change(&mut pin!(changes)).await; + assert_eq!(change[0].user_id, t.user_id()); + assert_eq!(change[0].changed_to, IdentityState::PinViolation); + assert_eq!(change.len(), 1); + } + + #[async_test] + async fn test_when_a_pinned_user_joins_we_do_not_report() { + // Given a room containing just us + let mut t = TestSetup::new_just_me_room().await; + + // And Bob's identity is unpinned + t.pin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + let mut changes = pin!(changes); + + // When Bob joins the room + t.join().await; + + // Then there is no notification + tokio::time::sleep(Duration::from_millis(200)).await; + let change = changes.next().now_or_never(); + assert!(change.is_none()); + } + + #[async_test] + async fn test_when_an_unpinned_user_leaves_we_report_it() { + // Given a room containing us and Bob + let mut t = TestSetup::new_room_with_other_member().await; + + // And Bob's identity is unpinned + t.unpin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + let mut changes = pin!(changes); + + // When Bob leaves the room + t.leave().await; + + // Then we were notified about the initial state of the room + let change1 = next_change(&mut changes).await; + assert_eq!(change1[0].user_id, t.user_id()); + assert_eq!(change1[0].changed_to, IdentityState::PinViolation); + assert_eq!(change1.len(), 1); + + // And we were notified about the change when the user left + let change2 = next_change(&mut changes).await; + // Note: the user left the room, but we see that as them "becoming pinned" i.e. + // "you no longer need to notify about this user". + assert_eq!(change2[0].user_id, t.user_id()); + assert_eq!(change2[0].changed_to, IdentityState::Pinned); + assert_eq!(change2.len(), 1); + } + + #[async_test] + async fn test_multiple_identity_changes_are_reported() { + // Given a room containing just us + let mut t = TestSetup::new_just_me_room().await; + + // And Bob's identity is unpinned + t.unpin().await; + + // And we are listening for identity changes + let changes = t.subscribe_to_identity_status_changes().await; + let mut changes = pin!(changes); + + // NOTE: below we pull the changes out of the subscription after each action. + // This makes sure that the identity changes and membership changes are + // properly ordered. If we pull them out later, the identity changes get + // shifted forward because they rely on less-complex async stuff under + // the hood. Calling next_change ends up winding the async + // machinery sufficiently that the membership change and any subsequent events + // have fully completed. + + // When Bob joins the room ... + t.join().await; + let change1 = next_change(&mut changes).await; + + // ... becomes pinned ... + t.pin().await; + let change2 = next_change(&mut changes).await; + + // ... leaves and joins again (ignored since they stay pinned) ... + t.leave().await; + t.join().await; + + // ... becomes unpinned ... + t.unpin().await; + let change3 = next_change(&mut changes).await; + + // ... and leaves. + t.leave().await; + let change4 = next_change(&mut changes).await; + + assert_eq!(change1[0].user_id, t.user_id()); + assert_eq!(change2[0].user_id, t.user_id()); + assert_eq!(change3[0].user_id, t.user_id()); + assert_eq!(change4[0].user_id, t.user_id()); + + assert_eq!(change1[0].changed_to, IdentityState::PinViolation); + assert_eq!(change2[0].changed_to, IdentityState::Pinned); + assert_eq!(change3[0].changed_to, IdentityState::PinViolation); + assert_eq!(change4[0].changed_to, IdentityState::Pinned); + + assert_eq!(change1.len(), 1); + assert_eq!(change2.len(), 1); + assert_eq!(change3.len(), 1); + assert_eq!(change4.len(), 1); + } + + // TODO: I (andyb) haven't figured out how to test room membership changes that + // affect our own user (they should not be shown). Specifically, I haven't + // figure out how to get out own user into a non-pinned state. + + async fn next_change( + changes: &mut Pin<&mut Timeout>>>, + ) -> Vec { + changes + .next() + .await + .expect("There should be an identity update") + .expect("Should not time out") + } + + mod test_setup { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use futures_core::Stream; + use matrix_sdk_base::{ + crypto::{IdentityStatusChange, OtherUserIdentity}, + RoomState, + }; + use matrix_sdk_test::{ + test_json::{self, keys_query_sets::IdentityChangeDataSet}, + JoinedRoomBuilder, StateTestEvent, SyncResponseBuilder, DEFAULT_TEST_ROOM_ID, + }; + use ruma::{ + api::client::keys::get_keys, events::room::member::MembershipState, owned_user_id, + OwnedUserId, TransactionId, UserId, + }; + use serde_json::json; + use tokio_stream::{StreamExt as _, Timeout}; + use wiremock::{ + matchers::{header, method, path_regex}, + Mock, MockServer, ResponseTemplate, + }; + + use crate::{ + encryption::identities::UserIdentity, test_utils::logged_in_client, Client, Room, + }; + + /// Sets up a client and a room and allows changing user identities and + /// room memberships. Note: most methods e.g. [`TestSetup::user_id`] are + /// talking about the OTHER user, not our own user. Only methods + /// starting with `self_` are talking about this user. + pub(super) struct TestSetup { + client: Client, + user_id: OwnedUserId, + sync_response_builder: SyncResponseBuilder, + room: Room, + } + + impl TestSetup { + pub(super) async fn new_just_me_room() -> Self { + let (client, user_id, mut sync_response_builder) = Self::init().await; + let room = create_just_me_room(&client, &mut sync_response_builder).await; + Self { client, user_id, sync_response_builder, room } + } + + pub(super) async fn new_room_with_other_member() -> Self { + let (client, user_id, mut sync_response_builder) = Self::init().await; + let room = + create_room_with_other_member(&mut sync_response_builder, &client, &user_id) + .await; + Self { client, user_id, sync_response_builder, room } + } + + pub(super) fn user_id(&self) -> &UserId { + &self.user_id + } + + pub(super) async fn pin(&self) { + if self.user_identity().await.is_some() { + assert!( + !self.is_pinned().await, + "pin() called when the identity is already pinned!" + ); + + // Pin it + self.crypto_other_identity() + .await + .pin_current_master_key() + .await + .expect("Should not fail to pin"); + } else { + // There was no existing identity. Set one. It will be pinned by default. + self.change_identity(IdentityChangeDataSet::key_query_with_identity_a()).await; + } + + // Sanity check: we are pinned + assert!(self.is_pinned().await); + } + + pub(super) async fn unpin(&self) { + // Change/set their identity - this will unpin if they already had one. + // If this was the first time we'd done this, they are now pinned. + self.change_identity(IdentityChangeDataSet::key_query_with_identity_a()).await; + + if self.is_pinned().await { + // Change their identity. Now they are definitely unpinned + self.change_identity(IdentityChangeDataSet::key_query_with_identity_b()).await; + } + + // Sanity: we are unpinned + assert!(!self.is_pinned().await); + } + + pub(super) async fn join(&mut self) { + self.membership_change(MembershipState::Join).await; + } + + pub(super) async fn leave(&mut self) { + self.membership_change(MembershipState::Leave).await; + } + + pub(super) async fn subscribe_to_identity_status_changes( + &self, + ) -> Timeout>> { + self.room + .subscribe_to_identity_status_changes() + .await + .expect("Should be able to subscribe") + .timeout(Duration::from_secs(2)) + } + + async fn init() -> (Client, OwnedUserId, SyncResponseBuilder) { + let (client, _server) = create_client_and_server().await; + + // Note: if you change the user_id, you will need to change lots of hard-coded + // stuff inside IdentityChangeDataSet + let user_id = owned_user_id!("@bob:localhost"); + + let sync_response_builder = SyncResponseBuilder::default(); + + (client, user_id, sync_response_builder) + } + + async fn change_identity( + &self, + key_query_response: get_keys::v3::Response, + ) -> OtherUserIdentity { + self.client + .mark_request_as_sent(&TransactionId::new(), &key_query_response) + .await + .expect("Should not fail to send identity changes"); + + self.crypto_other_identity().await + } + + async fn membership_change(&mut self, new_state: MembershipState) { + let sync_response = self + .sync_response_builder + .add_joined_room(JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID).add_state_event( + StateTestEvent::Custom(sync_response_member( + &self.user_id, + new_state.clone(), + )), + )) + .build_sync_response(); + self.room.client.process_sync(sync_response).await.unwrap(); + + // Make sure the membership stuck as expected + let m = self + .room + .get_member_no_sync(&self.user_id) + .await + .expect("Should not fail to get member"); + + match (&new_state, m) { + (MembershipState::Leave, None) => {} + (_, None) => { + panic!("Member should exist") + } + (_, Some(m)) => { + assert_eq!(*m.membership(), new_state); + } + }; + } + + async fn is_pinned(&self) -> bool { + !self.crypto_other_identity().await.identity_needs_user_approval() + } + + async fn crypto_other_identity(&self) -> OtherUserIdentity { + self.user_identity() + .await + .expect("User identity should exist") + .underlying_identity() + .other() + .expect("Identity should be Other, not Own") + } + + async fn user_identity(&self) -> Option { + self.client + .encryption() + .get_user_identity(&self.user_id) + .await + .expect("Should not fail to get user identity") + } + } + + async fn create_just_me_room( + client: &Client, + sync_response_builder: &mut SyncResponseBuilder, + ) -> Room { + let create_room_sync_response = sync_response_builder + .add_joined_room( + JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID) + .add_state_event(StateTestEvent::Member), + ) + .build_sync_response(); + client.process_sync(create_room_sync_response).await.unwrap(); + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).expect("Room should exist"); + assert_eq!(room.state(), RoomState::Joined); + room + } + + async fn create_room_with_other_member( + builder: &mut SyncResponseBuilder, + client: &Client, + other_user_id: &UserId, + ) -> Room { + let create_room_sync_response = builder + .add_joined_room( + JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID) + .add_state_event(StateTestEvent::Member) + .add_state_event(StateTestEvent::Custom(sync_response_member( + other_user_id, + MembershipState::Join, + ))), + ) + .build_sync_response(); + client.process_sync(create_room_sync_response).await.unwrap(); + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).expect("Room should exist"); + assert_eq!(room.state(), RoomState::Joined); + assert_eq!( + *room + .get_member_no_sync(other_user_id) + .await + .expect("Should not fail to get member") + .expect("Member should exist") + .membership(), + MembershipState::Join + ); + room + } + + async fn create_client_and_server() -> (Client, MockServer) { + let server = MockServer::start().await; + mock_members_request(&server).await; + mock_secret_storage_default_key(&server).await; + let client = logged_in_client(Some(server.uri())).await; + (client, server) + } + + async fn mock_members_request(server: &MockServer) { + Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/members")) + .and(header("authorization", "Bearer 1234")) + .respond_with( + ResponseTemplate::new(200).set_body_json(&*test_json::members::MEMBERS), + ) + .mount(&server) + .await; + } + + async fn mock_secret_storage_default_key(server: &MockServer) { + Mock::given(method("GET")) + .and(path_regex( + r"^/_matrix/client/r0/user/.*/account_data/m.secret_storage.default_key", + )) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + } + + fn sync_response_member( + user_id: &UserId, + membership: MembershipState, + ) -> serde_json::Value { + json!({ + "content": { + "membership": membership.to_string(), + }, + "event_id": format!( + "$aa{}bb:localhost", + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() % 100_000 + ), + "origin_server_ts": 1472735824, + "sender": "@example:localhost", + "state_key": user_id, + "type": "m.room.member", + "unsigned": { + "age": 1234 + } + }) + } + } +} From 78c8f13bd77fa3fee9a0268debab458ed9e5fa9b Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 20 Sep 2024 16:09:52 +0100 Subject: [PATCH 4/5] crypto: Provide a way to subscribe to identity status changes --- .../src/encryption/identities/users.rs | 1 + .../src/room/identity_status_changes.rs | 7 +- crates/matrix-sdk/src/room/mod.rs | 68 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/matrix-sdk/src/encryption/identities/users.rs b/crates/matrix-sdk/src/encryption/identities/users.rs index 8663b78e2ce..b2f989e46a8 100644 --- a/crates/matrix-sdk/src/encryption/identities/users.rs +++ b/crates/matrix-sdk/src/encryption/identities/users.rs @@ -108,6 +108,7 @@ impl UserIdentity { Self { inner: identity, client } } + #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] pub(crate) fn underlying_identity(&self) -> CryptoUserIdentities { self.inner.clone() } diff --git a/crates/matrix-sdk/src/room/identity_status_changes.rs b/crates/matrix-sdk/src/room/identity_status_changes.rs index 8185648f09c..ce93f326a46 100644 --- a/crates/matrix-sdk/src/room/identity_status_changes.rs +++ b/crates/matrix-sdk/src/room/identity_status_changes.rs @@ -13,6 +13,7 @@ // limitations under the License. //! Facility to track changes to the identity of members of rooms. +#![cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] use std::collections::BTreeMap; @@ -62,7 +63,7 @@ impl IdentityStatusChanges { /// For example, if an identity is "pinned" i.e. not manually verified, but /// known, and it becomes a "unpinned" i.e. unknown, because the /// encryption keys are different and the user has not acknowledged - /// this, then this constitues a status change. Also, if an identity is + /// this, then this constitutes a status change. Also, if an identity is /// "unpinned" and becomes "pinned", this is also a status change. /// /// The supplied stream is intended to provide enough information for a @@ -612,7 +613,7 @@ mod tests { .respond_with( ResponseTemplate::new(200).set_body_json(&*test_json::members::MEMBERS), ) - .mount(&server) + .mount(server) .await; } @@ -623,7 +624,7 @@ mod tests { )) .and(header("authorization", "Bearer 1234")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) + .mount(server) .await; } diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 20ad04d6adb..4c5a83ced86 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -8,14 +8,20 @@ use std::{ time::Duration, }; +#[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] +use async_trait::async_trait; use eyeball::SharedObservable; use futures_core::Stream; use futures_util::{ future::{try_join, try_join_all}, stream::FuturesUnordered, }; +#[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; +#[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] +use matrix_sdk_base::crypto::{IdentityStatusChange, RoomIdentityProvider, UserIdentity}; use matrix_sdk_base::{ deserialized_responses::{ RawAnySyncOrStrippedState, RawSyncOrStrippedState, SyncOrStrippedState, TimelineEvent, @@ -114,6 +120,7 @@ use crate::{ pub mod edit; pub mod futures; +pub mod identity_status_changes; mod member; mod messages; pub mod power_levels; @@ -367,6 +374,35 @@ impl Room { (drop_guard, receiver) } + /// Subscribe to updates about users who are in "pin violation" i.e. their + /// identity has changed and the user has not yet acknowledged this. + /// + /// The returned receiver will receive a new vector of + /// [`IdentityStatusChange`] each time a /keys/query response shows a + /// changed identity for a member of this room, or a sync shows a change + /// to the membership of an affected user. (Changes to the current user are + /// not directly included, but some changes to the current user's identity + /// can trigger changes to how we see other users' identities, which + /// will be included.) + /// + /// The first item in the stream provides the current state of the room: + /// each member of the room who is not in "pinned" state will be + /// included (except the current user). + /// + /// If the `changed_to` property of an [`IdentityStatusChange`] is set to + /// `PinViolation` then a warning should be displayed to the user. If it is + /// set to `Pinned` then no warning should be displayed. + /// + /// Note that if a user who is in pin violation leaves the room, a `Pinned` + /// update is sent, to indicate that the warning should be removed, even + /// though the user's identity is not necessarily pinned. + #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] + pub async fn subscribe_to_identity_status_changes( + &self, + ) -> Result>> { + IdentityStatusChanges::create_stream(self.clone()).await + } + /// Returns a wrapping `TimelineEvent` for the input `AnyTimelineEvent`, /// decrypted if needs be. /// @@ -2925,6 +2961,38 @@ impl Room { } } +#[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] +#[async_trait] +impl RoomIdentityProvider for Room { + async fn is_member(&self, user_id: &UserId) -> bool { + self.get_member(user_id).await.unwrap_or(None).is_some() + } + + async fn member_identities(&self) -> Vec { + let members = self + .members(RoomMemberships::JOIN | RoomMemberships::INVITE) + .await + .unwrap_or_else(|_| Default::default()); + + let mut ret: Vec = Vec::new(); + for member in members { + if let Some(i) = self.user_identity(member.user_id()).await { + ret.push(i); + } + } + ret + } + + async fn user_identity(&self, user_id: &UserId) -> Option { + self.client + .encryption() + .get_user_identity(user_id) + .await + .unwrap_or(None) + .map(|u| u.underlying_identity()) + } +} + /// A wrapper for a weak client and a room id that allows to lazily retrieve a /// room, only when needed. #[derive(Clone)] From 7ebc7270392c75af7eeb328f48b0d9a18e09c6b8 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 20 Sep 2024 17:37:43 +0100 Subject: [PATCH 5/5] crypto: FFI bindings for subscribe_to_identity_status_changes --- .../src/identity_status_change.rs | 24 +++++++++++++ bindings/matrix-sdk-ffi/src/lib.rs | 1 + bindings/matrix-sdk-ffi/src/room.rs | 34 ++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 bindings/matrix-sdk-ffi/src/identity_status_change.rs diff --git a/bindings/matrix-sdk-ffi/src/identity_status_change.rs b/bindings/matrix-sdk-ffi/src/identity_status_change.rs new file mode 100644 index 00000000000..3fcb5a69142 --- /dev/null +++ b/bindings/matrix-sdk-ffi/src/identity_status_change.rs @@ -0,0 +1,24 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use matrix_sdk::crypto::IdentityState; + +#[derive(uniffi::Record)] +pub struct IdentityStatusChange { + /// The user ID of the user whose identity status changed + pub user_id: String, + + /// The new state of the identity of the user. + pub changed_to: IdentityState, +} diff --git a/bindings/matrix-sdk-ffi/src/lib.rs b/bindings/matrix-sdk-ffi/src/lib.rs index 47ef7240d3d..801887b6fee 100644 --- a/bindings/matrix-sdk-ffi/src/lib.rs +++ b/bindings/matrix-sdk-ffi/src/lib.rs @@ -11,6 +11,7 @@ mod encryption; mod error; mod event; mod helpers; +mod identity_status_change; mod notification; mod notification_settings; mod platform; diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index c736007e381..8c21f7ca55f 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -1,6 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, pin::pin, sync::Arc}; use anyhow::{Context, Result}; +use futures_util::StreamExt; use matrix_sdk::{ crypto::LocalTrust, event_cache::paginator::PaginatorError, @@ -35,6 +36,7 @@ use crate::{ chunk_iterator::ChunkIterator, error::{ClientError, MediaInfoError, RoomError}, event::{MessageLikeEventType, StateEventType}, + identity_status_change::IdentityStatusChange, room_info::RoomInfo, room_member::RoomMember, ruma::{ImageInfo, Mentions, NotifyType}, @@ -582,6 +584,31 @@ impl Room { }))) } + pub fn subscribe_to_identity_status_changes( + &self, + listener: Box, + ) -> Arc { + let room = self.inner.clone(); + Arc::new(TaskHandle::new(RUNTIME.spawn(async move { + let status_changes = room.subscribe_to_identity_status_changes().await; + if let Ok(status_changes) = status_changes { + // TODO: what to do with failures? + let mut status_changes = pin!(status_changes); + while let Some(identity_status_changes) = status_changes.next().await { + listener.call( + identity_status_changes + .into_iter() + .map(|change| { + let user_id = change.user_id.to_string(); + IdentityStatusChange { user_id, changed_to: change.changed_to } + }) + .collect(), + ); + } + } + }))) + } + /// Set (or unset) a flag on the room to indicate that the user has /// explicitly marked it as unread. pub async fn set_unread_flag(&self, new_value: bool) -> Result<(), ClientError> { @@ -898,6 +925,11 @@ pub trait TypingNotificationsListener: Sync + Send { fn call(&self, typing_user_ids: Vec); } +#[uniffi::export(callback_interface)] +pub trait IdentityStatusChangeListener: Sync + Send { + fn call(&self, identity_status_change: Vec); +} + #[derive(uniffi::Object)] pub struct RoomMembersIterator { chunk_iterator: ChunkIterator,