matrix-sdk 0.17.0

A high level Matrix client-server library.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
// Copyright 2025 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::HashSet, iter, time::Duration};

use matrix_sdk_base::{
    RoomState,
    crypto::{
        store::types::{Changes, RoomKeyBundleInfo, RoomPendingKeyBundleDetails},
        types::events::room_key_bundle::RoomKeyBundleContent,
    },
    media::{MediaFormat, MediaRequestParameters},
};
use ruma::{
    OwnedUserId, UserId,
    api::error::ErrorKind,
    events::room::{MediaSource, history_visibility::HistoryVisibility},
};
use tracing::{debug, info, instrument, warn};

use crate::{Error, Result, Room};

/// Share any shareable E2EE history in the given room with the given recipient,
/// as per [MSC4268].
///
/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
#[instrument(skip(room), fields(room_id = ?room.room_id()))]
pub(super) async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> {
    let client = &room.client;

    // 0.a. We can only share room history if our user has set up cross signing
    let own_identity = match client.user_id() {
        Some(own_user) => client.encryption().get_user_identity(own_user).await?,
        None => None,
    };

    if own_identity.is_none() {
        warn!("Not sharing message history as cross-signing is not set up");
        return Ok(());
    }

    // 0.b. We should only share room history if the *current* visibility allows it.
    //      Note: the specification states we should assume `shared` if no event
    //      exists, see https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-7.
    if matches!(
        room.history_visibility_or_default(),
        HistoryVisibility::Joined | HistoryVisibility::Invited
    ) {
        debug!("Not sharing message history as the room history visibility is currently unshared");
        return Ok(());
    }

    info!("Sharing message history");

    let olm_machine = client.olm_machine().await;
    let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?;

    // 1. Download all available room keys from backup if we haven't already.
    if !olm_machine.store().has_downloaded_all_room_keys(room.room_id()).await? {
        debug!("Downloading room keys for room");
        client.encryption().backups().download_room_keys_for_room(room.room_id()).await?;
        olm_machine
            .store()
            .save_changes(Changes {
                room_key_backups_fully_downloaded: HashSet::from_iter([room.room_id().to_owned()]),
                ..Default::default()
            })
            .await?;
    }

    // 2. Construct the key bundle
    let bundle = olm_machine.store().build_room_key_bundle(room.room_id()).await?;

    if bundle.is_empty() {
        info!("No keys to share");
        return Ok(());
    }

    // 3. Upload to the server as an encrypted file
    let json = serde_json::to_vec(&bundle)?;
    let upload = client.upload_encrypted_file(&mut (json.as_slice())).await?;

    info!(
        media_url = ?upload.url,
        shared_keys = bundle.room_keys.len(),
        withheld_keys = bundle.withheld.len(),
        "Uploaded encrypted key blob"
    );

    // 4. Ensure that we get a fresh list of devices for the invited user.
    let (req_id, request) = olm_machine.query_keys_for_users(iter::once(user_id.as_ref()));

    if !request.device_keys.is_empty() {
        room.client.keys_query(&req_id, request.device_keys).await?;
    }

    // 5. Establish Olm sessions with all of the recipient's devices.
    client.claim_one_time_keys(iter::once(user_id.as_ref())).await?;

    // 6. Send to-device messages to the recipient to share the keys.
    let content = RoomKeyBundleContent { room_id: room.room_id().to_owned(), file: upload };
    let requests = {
        let olm_machine = client.olm_machine().await;
        let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?;
        olm_machine
            .share_room_key_bundle_data(
                &user_id,
                &client.base_client().room_key_recipient_strategy,
                content,
            )
            .await?
    };

    for request in requests {
        let response = client.send_to_device(&request).await?;
        client.mark_request_as_sent(&request.txn_id, &response).await?;
    }

    Ok(())
}

/// Determines whether a room key bundle should be accepted for a given room.
///
/// This function checks if the client has recorded invite acceptance details
/// for the room and ensures that the bundle sender matches the inviter.
/// Additionally, it verifies that the room is in a joined state and that the
/// bundle is received within one day of the invite being accepted.
///
/// # Arguments
///
/// * `room` - The room for which the key bundle acceptance is being evaluated.
/// * `bundle_info` - Information about the room key bundle being evaluated.
///
/// # Returns
///
/// Returns `true` if the key bundle should be accepted, otherwise `false`.
pub(crate) async fn should_accept_key_bundle(room: &Room, bundle_info: &RoomKeyBundleInfo) -> bool {
    // If we don't have any invite acceptance details, then this client wasn't the
    // one that accepted the invite.
    let Ok(Some(details)) =
        room.client.base_client().get_pending_key_bundle_details_for_room(room.room_id()).await
    else {
        debug!("Not accepting key bundle as there are no recorded invite acceptance details");
        return false;
    };

    if !should_process_room_pending_key_bundle_details(&details) {
        return false;
    }

    let state = room.state();
    let bundle_sender = &bundle_info.sender;

    match state {
        RoomState::Joined => bundle_sender == &details.inviter,
        RoomState::Left | RoomState::Invited | RoomState::Knocked | RoomState::Banned => false,
    }
}

/// Determines whether the pending key bundle details for a room should be
/// processed.
///
/// This function checks if the invite acceptance timestamp is within the
/// allowed time window (one day). If the elapsed time since the invite was
/// accepted exceeds this window, the pending key bundle details will not be
/// processed.
///
/// # Arguments
///
/// * `details` - The details of the pending key bundle, including the invite
///   acceptance timestamp.
///
/// # Returns
///
/// Returns `true` if the pending key bundle details should be processed,
/// otherwise `false`.
pub(crate) fn should_process_room_pending_key_bundle_details(
    details: &RoomPendingKeyBundleDetails,
) -> bool {
    // We accept historic room key bundles up to one day after we have accepted an
    // invite.
    const DAY: Duration = Duration::from_secs(24 * 60 * 60);

    details
        .invite_accepted_at
        .to_system_time()
        .and_then(|t| t.elapsed().ok())
        .map(|elapsed_since_join| elapsed_since_join < DAY)
        .unwrap_or(false)
}

/// Having accepted an invite for the given room from the given user, attempt to
/// find a information about a room key bundle and, if found, download the
/// bundle and import the room keys, as per [MSC4268].
///
/// # Arguments
///
/// * `room` - The room we were invited to, for which we want to check if a room
///   key bundle was received.
///
/// * `inviter` - The user who invited us to the room and is expected to have
///   sent the room key bundle.
///
/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
#[instrument(skip(room), fields(room_id = ?room.room_id(), bundle_sender))]
pub(crate) async fn maybe_accept_key_bundle(room: &Room, inviter: &UserId) -> Result<()> {
    // TODO: retry this if it gets interrupted or it fails.
    // TODO: do this in the background.

    let client = &room.client;
    let olm_machine = client.olm_machine().await;

    let Some(olm_machine) = olm_machine.as_ref() else {
        warn!("Not fetching room key bundle as the Olm machine is not available");
        return Ok(());
    };

    let Some(bundle_info) =
        olm_machine.store().get_received_room_key_bundle_data(room.room_id(), inviter).await?
    else {
        // No bundle received (yet).
        info!("No room key bundle from inviter found");
        return Ok(());
    };

    tracing::Span::current().record("bundle_sender", bundle_info.sender_user.as_str());

    // Ensure that we get a fresh list of devices for the inviter, in case we need
    // to recalculate the `SenderData`.
    // XXX: is this necessary, given (with exclude-insecure-devices), we should have
    // checked that the inviter device was cross-signed when we received the
    // to-device message?
    let (req_id, request) =
        olm_machine.query_keys_for_users(iter::once(bundle_info.sender_user.as_ref()));

    if !request.device_keys.is_empty() {
        room.client.keys_query(&req_id, request.device_keys).await?;
    }

    let bundle_content = match client
        .media()
        .get_media_content(
            &MediaRequestParameters {
                source: MediaSource::Encrypted(Box::new(bundle_info.bundle_data.file.clone())),
                format: MediaFormat::File,
            },
            false,
        )
        .await
    {
        Ok(bundle_content) => bundle_content,
        Err(err) => {
            // If we encountered an HTTP client error, we should check the status code to
            // see if we have been sent a bogus link.
            let Some(err) = err
                .as_ruma_api_error()
                .and_then(|e| e.as_client_api_error())
                .and_then(|e| e.error_kind())
            else {
                // Some other error occurred, which we may be able to recover from at the next
                // client startup.
                return Ok(());
            };

            if ErrorKind::NotFound == *err {
                // Clear the pending flag since checking these details again at startup are
                // guaranteed to fail.
                olm_machine.store().clear_room_pending_key_bundle(room.room_id()).await?;
            }

            return Ok(());
        }
    };

    match serde_json::from_slice(&bundle_content) {
        Ok(bundle) => {
            olm_machine
                .store()
                .receive_room_key_bundle(
                    &bundle_info,
                    bundle,
                    // TODO: Use the progress listener and expose an argument for it.
                    |_, _| {},
                )
                .await?;
        }
        Err(err) => {
            warn!("Failed to deserialize room key bundle: {err}");
        }
    }

    // TODO: Now that we downloaded and imported the bundle, or the bundle was
    // invalid, we can safely remove the info about the bundle.
    // olm_machine.store().clear_received_room_key_bundle_data(room.room_id(),
    // user_id).await?;

    // If we have reached this point, the bundle was either successfully imported,
    // or was malformed and failed to deserialise. In either case, we can clear
    // the room pending state.
    olm_machine.store().clear_room_pending_key_bundle(room.room_id()).await?;

    Ok(())
}

#[cfg(all(test, not(target_family = "wasm")))]
mod test {
    use matrix_sdk_base::crypto::store::types::RoomKeyBundleInfo;
    use matrix_sdk_test::{
        InvitedRoomBuilder, JoinedRoomBuilder, async_test, event_factory::EventFactory,
    };
    use ruma::{room_id, user_id};
    use vodozemac::Curve25519PublicKey;

    use crate::{room::shared_room_history, test_utils::mocks::MatrixMockServer};

    /// Test that ensures that we only accept a bundle if a certain set of
    /// conditions is met.
    #[async_test]
    async fn test_should_accept_bundle() {
        let server = MatrixMockServer::new().await;

        let alice_user_id = user_id!("@alice:localhost");
        let bob_user_id = user_id!("@bob:localhost");
        let joined_room_id = room_id!("!joined:localhost");
        let invited_rom_id = room_id!("!invited:localhost");

        let client = server
            .client_builder()
            .logged_in_with_token("ABCD".to_owned(), alice_user_id.into(), "DEVICEID".into())
            .build()
            .await;

        let event_factory = EventFactory::new().room(invited_rom_id);
        let bob_member_event = event_factory.member(bob_user_id);
        let alice_member_event = event_factory.member(bob_user_id).invited(alice_user_id);

        server
            .mock_sync()
            .ok_and_run(&client, |builder| {
                builder.add_joined_room(JoinedRoomBuilder::new(joined_room_id)).add_invited_room(
                    InvitedRoomBuilder::new(invited_rom_id)
                        .add_state_event(bob_member_event)
                        .add_state_event(alice_member_event),
                );
            })
            .await;

        let room =
            client.get_room(joined_room_id).expect("We should have access to our joined room now");

        assert!(
            client
                .base_client()
                .get_pending_key_bundle_details_for_room(room.room_id())
                .await
                .unwrap()
                .is_none(),
            "We shouldn't have any invite acceptance details if we didn't join the room on this Client"
        );

        let bundle_info = RoomKeyBundleInfo {
            sender: bob_user_id.to_owned(),
            sender_key: Curve25519PublicKey::from_bytes([0u8; 32]),
            room_id: joined_room_id.to_owned(),
        };

        assert!(
            !shared_room_history::should_accept_key_bundle(&room, &bundle_info).await,
            "We should not accept a bundle if we did not join the room from this Client"
        );

        let invited_room =
            client.get_room(invited_rom_id).expect("We should have access to our invited room now");

        assert!(
            !shared_room_history::should_accept_key_bundle(&invited_room, &bundle_info).await,
            "We should not accept a bundle if we didn't join the room."
        );

        server.mock_room_join(invited_rom_id).ok().mock_once().mount().await;

        let room = client
            .join_room_by_id(invited_rom_id)
            .await
            .expect("We should be able to join the invited room");

        let details = client
            .base_client()
            .get_pending_key_bundle_details_for_room(room.room_id())
            .await
            .unwrap()
            .expect("We should have stored the invite acceptance details");
        assert_eq!(details.inviter, bob_user_id, "We should have recorded that Bob has invited us");

        assert!(
            shared_room_history::should_accept_key_bundle(&room, &bundle_info).await,
            "We should accept a bundle if we just joined the room and did so from this very Client object"
        );
    }
}