Skip to main content

matrix_sdk/room/
shared_room_history.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{collections::HashSet, iter, time::Duration};
16
17use matrix_sdk_base::{
18    RoomState,
19    crypto::{
20        store::types::{Changes, RoomKeyBundleInfo, RoomPendingKeyBundleDetails},
21        types::events::room_key_bundle::RoomKeyBundleContent,
22    },
23    media::{MediaFormat, MediaRequestParameters},
24};
25use ruma::{
26    OwnedUserId, UserId,
27    api::error::ErrorKind,
28    events::room::{MediaSource, history_visibility::HistoryVisibility},
29};
30use tracing::{debug, info, instrument, warn};
31
32use crate::{Error, Result, Room};
33
34/// Share any shareable E2EE history in the given room with the given recipient,
35/// as per [MSC4268].
36///
37/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
38#[instrument(skip(room), fields(room_id = ?room.room_id()))]
39pub(super) async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> {
40    let client = &room.client;
41
42    // 0.a. We can only share room history if our user has set up cross signing
43    let own_identity = match client.user_id() {
44        Some(own_user) => client.encryption().get_user_identity(own_user).await?,
45        None => None,
46    };
47
48    if own_identity.is_none() {
49        warn!("Not sharing message history as cross-signing is not set up");
50        return Ok(());
51    }
52
53    // 0.b. We should only share room history if the *current* visibility allows it.
54    //      Note: the specification states we should assume `shared` if no event
55    //      exists, see https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-7.
56    if matches!(
57        room.history_visibility_or_default(),
58        HistoryVisibility::Joined | HistoryVisibility::Invited
59    ) {
60        debug!("Not sharing message history as the room history visibility is currently unshared");
61        return Ok(());
62    }
63
64    info!("Sharing message history");
65
66    let olm_machine = client.olm_machine().await;
67    let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?;
68
69    // 1. Download all available room keys from backup if we haven't already.
70    if !olm_machine.store().has_downloaded_all_room_keys(room.room_id()).await? {
71        debug!("Downloading room keys for room");
72        client.encryption().backups().download_room_keys_for_room(room.room_id()).await?;
73        olm_machine
74            .store()
75            .save_changes(Changes {
76                room_key_backups_fully_downloaded: HashSet::from_iter([room.room_id().to_owned()]),
77                ..Default::default()
78            })
79            .await?;
80    }
81
82    // 2. Construct the key bundle
83    let bundle = olm_machine.store().build_room_key_bundle(room.room_id()).await?;
84
85    if bundle.is_empty() {
86        info!("No keys to share");
87        return Ok(());
88    }
89
90    // 3. Upload to the server as an encrypted file
91    let json = serde_json::to_vec(&bundle)?;
92    let upload = client.upload_encrypted_file(&mut (json.as_slice())).await?;
93
94    info!(
95        media_url = ?upload.url,
96        shared_keys = bundle.room_keys.len(),
97        withheld_keys = bundle.withheld.len(),
98        "Uploaded encrypted key blob"
99    );
100
101    // 4. Ensure that we get a fresh list of devices for the invited user.
102    let (req_id, request) = olm_machine.query_keys_for_users(iter::once(user_id.as_ref()));
103
104    if !request.device_keys.is_empty() {
105        room.client.keys_query(&req_id, request.device_keys).await?;
106    }
107
108    // 5. Establish Olm sessions with all of the recipient's devices.
109    client.claim_one_time_keys(iter::once(user_id.as_ref())).await?;
110
111    // 6. Send to-device messages to the recipient to share the keys.
112    let content = RoomKeyBundleContent { room_id: room.room_id().to_owned(), file: upload };
113    let requests = {
114        let olm_machine = client.olm_machine().await;
115        let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?;
116        olm_machine
117            .share_room_key_bundle_data(
118                &user_id,
119                &client.base_client().room_key_recipient_strategy,
120                content,
121            )
122            .await?
123    };
124
125    for request in requests {
126        let response = client.send_to_device(&request).await?;
127        client.mark_request_as_sent(&request.txn_id, &response).await?;
128    }
129
130    Ok(())
131}
132
133/// Determines whether a room key bundle should be accepted for a given room.
134///
135/// This function checks if the client has recorded invite acceptance details
136/// for the room and ensures that the bundle sender matches the inviter.
137/// Additionally, it verifies that the room is in a joined state and that the
138/// bundle is received within one day of the invite being accepted.
139///
140/// # Arguments
141///
142/// * `room` - The room for which the key bundle acceptance is being evaluated.
143/// * `bundle_info` - Information about the room key bundle being evaluated.
144///
145/// # Returns
146///
147/// Returns `true` if the key bundle should be accepted, otherwise `false`.
148pub(crate) async fn should_accept_key_bundle(room: &Room, bundle_info: &RoomKeyBundleInfo) -> bool {
149    // If we don't have any invite acceptance details, then this client wasn't the
150    // one that accepted the invite.
151    let Ok(Some(details)) =
152        room.client.base_client().get_pending_key_bundle_details_for_room(room.room_id()).await
153    else {
154        debug!("Not accepting key bundle as there are no recorded invite acceptance details");
155        return false;
156    };
157
158    if !should_process_room_pending_key_bundle_details(&details) {
159        return false;
160    }
161
162    let state = room.state();
163    let bundle_sender = &bundle_info.sender;
164
165    match state {
166        RoomState::Joined => bundle_sender == &details.inviter,
167        RoomState::Left | RoomState::Invited | RoomState::Knocked | RoomState::Banned => false,
168    }
169}
170
171/// Determines whether the pending key bundle details for a room should be
172/// processed.
173///
174/// This function checks if the invite acceptance timestamp is within the
175/// allowed time window (one day). If the elapsed time since the invite was
176/// accepted exceeds this window, the pending key bundle details will not be
177/// processed.
178///
179/// # Arguments
180///
181/// * `details` - The details of the pending key bundle, including the invite
182///   acceptance timestamp.
183///
184/// # Returns
185///
186/// Returns `true` if the pending key bundle details should be processed,
187/// otherwise `false`.
188pub(crate) fn should_process_room_pending_key_bundle_details(
189    details: &RoomPendingKeyBundleDetails,
190) -> bool {
191    // We accept historic room key bundles up to one day after we have accepted an
192    // invite.
193    const DAY: Duration = Duration::from_secs(24 * 60 * 60);
194
195    details
196        .invite_accepted_at
197        .to_system_time()
198        .and_then(|t| t.elapsed().ok())
199        .map(|elapsed_since_join| elapsed_since_join < DAY)
200        .unwrap_or(false)
201}
202
203/// Having accepted an invite for the given room from the given user, attempt to
204/// find a information about a room key bundle and, if found, download the
205/// bundle and import the room keys, as per [MSC4268].
206///
207/// # Arguments
208///
209/// * `room` - The room we were invited to, for which we want to check if a room
210///   key bundle was received.
211///
212/// * `inviter` - The user who invited us to the room and is expected to have
213///   sent the room key bundle.
214///
215/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
216#[instrument(skip(room), fields(room_id = ?room.room_id(), bundle_sender))]
217pub(crate) async fn maybe_accept_key_bundle(room: &Room, inviter: &UserId) -> Result<()> {
218    // TODO: retry this if it gets interrupted or it fails.
219    // TODO: do this in the background.
220
221    let client = &room.client;
222    let olm_machine = client.olm_machine().await;
223
224    let Some(olm_machine) = olm_machine.as_ref() else {
225        warn!("Not fetching room key bundle as the Olm machine is not available");
226        return Ok(());
227    };
228
229    let Some(bundle_info) =
230        olm_machine.store().get_received_room_key_bundle_data(room.room_id(), inviter).await?
231    else {
232        // No bundle received (yet).
233        info!("No room key bundle from inviter found");
234        return Ok(());
235    };
236
237    tracing::Span::current().record("bundle_sender", bundle_info.sender_user.as_str());
238
239    // Ensure that we get a fresh list of devices for the inviter, in case we need
240    // to recalculate the `SenderData`.
241    // XXX: is this necessary, given (with exclude-insecure-devices), we should have
242    // checked that the inviter device was cross-signed when we received the
243    // to-device message?
244    let (req_id, request) =
245        olm_machine.query_keys_for_users(iter::once(bundle_info.sender_user.as_ref()));
246
247    if !request.device_keys.is_empty() {
248        room.client.keys_query(&req_id, request.device_keys).await?;
249    }
250
251    let bundle_content = match client
252        .media()
253        .get_media_content(
254            &MediaRequestParameters {
255                source: MediaSource::Encrypted(Box::new(bundle_info.bundle_data.file.clone())),
256                format: MediaFormat::File,
257            },
258            false,
259        )
260        .await
261    {
262        Ok(bundle_content) => bundle_content,
263        Err(err) => {
264            // If we encountered an HTTP client error, we should check the status code to
265            // see if we have been sent a bogus link.
266            let Some(err) = err
267                .as_ruma_api_error()
268                .and_then(|e| e.as_client_api_error())
269                .and_then(|e| e.error_kind())
270            else {
271                // Some other error occurred, which we may be able to recover from at the next
272                // client startup.
273                return Ok(());
274            };
275
276            if ErrorKind::NotFound == *err {
277                // Clear the pending flag since checking these details again at startup are
278                // guaranteed to fail.
279                olm_machine.store().clear_room_pending_key_bundle(room.room_id()).await?;
280            }
281
282            return Ok(());
283        }
284    };
285
286    match serde_json::from_slice(&bundle_content) {
287        Ok(bundle) => {
288            olm_machine
289                .store()
290                .receive_room_key_bundle(
291                    &bundle_info,
292                    bundle,
293                    // TODO: Use the progress listener and expose an argument for it.
294                    |_, _| {},
295                )
296                .await?;
297        }
298        Err(err) => {
299            warn!("Failed to deserialize room key bundle: {err}");
300        }
301    }
302
303    // TODO: Now that we downloaded and imported the bundle, or the bundle was
304    // invalid, we can safely remove the info about the bundle.
305    // olm_machine.store().clear_received_room_key_bundle_data(room.room_id(),
306    // user_id).await?;
307
308    // If we have reached this point, the bundle was either successfully imported,
309    // or was malformed and failed to deserialise. In either case, we can clear
310    // the room pending state.
311    olm_machine.store().clear_room_pending_key_bundle(room.room_id()).await?;
312
313    Ok(())
314}
315
316#[cfg(all(test, not(target_family = "wasm")))]
317mod test {
318    use matrix_sdk_base::crypto::store::types::RoomKeyBundleInfo;
319    use matrix_sdk_test::{
320        InvitedRoomBuilder, JoinedRoomBuilder, async_test, event_factory::EventFactory,
321    };
322    use ruma::{room_id, user_id};
323    use vodozemac::Curve25519PublicKey;
324
325    use crate::{room::shared_room_history, test_utils::mocks::MatrixMockServer};
326
327    /// Test that ensures that we only accept a bundle if a certain set of
328    /// conditions is met.
329    #[async_test]
330    async fn test_should_accept_bundle() {
331        let server = MatrixMockServer::new().await;
332
333        let alice_user_id = user_id!("@alice:localhost");
334        let bob_user_id = user_id!("@bob:localhost");
335        let joined_room_id = room_id!("!joined:localhost");
336        let invited_rom_id = room_id!("!invited:localhost");
337
338        let client = server
339            .client_builder()
340            .logged_in_with_token("ABCD".to_owned(), alice_user_id.into(), "DEVICEID".into())
341            .build()
342            .await;
343
344        let event_factory = EventFactory::new().room(invited_rom_id);
345        let bob_member_event = event_factory.member(bob_user_id);
346        let alice_member_event = event_factory.member(bob_user_id).invited(alice_user_id);
347
348        server
349            .mock_sync()
350            .ok_and_run(&client, |builder| {
351                builder.add_joined_room(JoinedRoomBuilder::new(joined_room_id)).add_invited_room(
352                    InvitedRoomBuilder::new(invited_rom_id)
353                        .add_state_event(bob_member_event)
354                        .add_state_event(alice_member_event),
355                );
356            })
357            .await;
358
359        let room =
360            client.get_room(joined_room_id).expect("We should have access to our joined room now");
361
362        assert!(
363            client
364                .base_client()
365                .get_pending_key_bundle_details_for_room(room.room_id())
366                .await
367                .unwrap()
368                .is_none(),
369            "We shouldn't have any invite acceptance details if we didn't join the room on this Client"
370        );
371
372        let bundle_info = RoomKeyBundleInfo {
373            sender: bob_user_id.to_owned(),
374            sender_key: Curve25519PublicKey::from_bytes([0u8; 32]),
375            room_id: joined_room_id.to_owned(),
376        };
377
378        assert!(
379            !shared_room_history::should_accept_key_bundle(&room, &bundle_info).await,
380            "We should not accept a bundle if we did not join the room from this Client"
381        );
382
383        let invited_room =
384            client.get_room(invited_rom_id).expect("We should have access to our invited room now");
385
386        assert!(
387            !shared_room_history::should_accept_key_bundle(&invited_room, &bundle_info).await,
388            "We should not accept a bundle if we didn't join the room."
389        );
390
391        server.mock_room_join(invited_rom_id).ok().mock_once().mount().await;
392
393        let room = client
394            .join_room_by_id(invited_rom_id)
395            .await
396            .expect("We should be able to join the invited room");
397
398        let details = client
399            .base_client()
400            .get_pending_key_bundle_details_for_room(room.room_id())
401            .await
402            .unwrap()
403            .expect("We should have stored the invite acceptance details");
404        assert_eq!(details.inviter, bob_user_id, "We should have recorded that Bob has invited us");
405
406        assert!(
407            shared_room_history::should_accept_key_bundle(&room, &bundle_info).await,
408            "We should accept a bundle if we just joined the room and did so from this very Client object"
409        );
410    }
411}