Skip to content

feat(widget): Send custom to-device messages in widgets in e2ee rooms #5252

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

BillCarsonFr
Copy link
Member

@BillCarsonFr BillCarsonFr commented Jun 19, 2025

Adds the capability for widgets to send encrytped to-device messages properly.

It is getting rid of the old encrypted boolean that was used by widget to request encryption. As said by the base MSC
for widget send/receive messages: Widgets should not be aware of encryption.
If the widget is in an encrytped room then to-device traffic will be encrypted.

Fixes #4859

  • Public API changes documented in changelogs (optional)

Signed-off-by:

@BillCarsonFr BillCarsonFr changed the title Valere/crypto/widget send encrypted to device2 feat(widget): Send custom to-device messages in widgets in e2ee rooms Jun 19, 2025
@BillCarsonFr BillCarsonFr force-pushed the valere/crypto/widget_send_encrypted_to_device2 branch from 29cabcc to 0b565d0 Compare June 19, 2025 09:12
Copy link

codecov bot commented Jun 19, 2025

Codecov Report

Attention: Patch coverage is 92.98246% with 16 lines in your changes missing coverage. Please review.

Project coverage is 88.75%. Comparing base (1aa933c) to head (76a9446).
Report is 43 commits behind head on main.

✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
crates/matrix-sdk/src/widget/matrix.rs 88.99% 5 Missing and 7 partials ⚠️
...ates/matrix-sdk/src/test_utils/mocks/encryption.rs 98.07% 1 Missing and 1 partial ⚠️
...rates/matrix-sdk/src/widget/machine/from_widget.rs 66.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5252      +/-   ##
==========================================
- Coverage   90.20%   88.75%   -1.45%     
==========================================
  Files         334      330       -4     
  Lines      105133    88874   -16259     
  Branches   105133    88874   -16259     
==========================================
- Hits        94831    78884   -15947     
+ Misses       6238     6176      -62     
+ Partials     4064     3814     -250     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

if room_encrypted {
trace!("Sending to-device message in encrypted room <{}>", self.room.room_id());

// Attempt to re-batch these by content to batch them up into a single request
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is a bit annoying, and the root cause is I believe a bad design choice on the widget API that wants the same structure than /sendToDevice HTTP API.
It is annoying because the crypto API allows to encrypt a same content for a list of recipients (not a bag of different contents). I added a comment on the MSC matrix-org/matrix-spec-proposals#3819 (comment)

but anyhow, for now we have to invert this map and re-group per similar content (have to serialise for checking that)

/// It is possible that sending to some device failed (failed to encrypt or
/// failed to send)
#[serde(skip_serializing_if = "Option::is_none")]
pub failures: Option<BTreeMap<String, Vec<String>>>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now there is no reason/code for the failures. Will be handled in a follow-up PR

@BillCarsonFr BillCarsonFr marked this pull request as ready for review June 19, 2025 09:25
@BillCarsonFr BillCarsonFr requested a review from a team as a code owner June 19, 2025 09:25
@BillCarsonFr BillCarsonFr requested review from poljar and removed request for a team June 19, 2025 09:25
Copy link
Contributor

@poljar poljar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks mostly good, there is some surprising logic that we could make a bit more obvious and perhaps even more performant.

Comment on lines 360 to 374
let mut content_grouped: BTreeMap<
&str,
BTreeMap<OwnedUserId, Vec<DeviceIdOrAllDevices>>,
> = BTreeMap::new();

for (user_id, device_map) in messages.iter() {
for (device_id, content) in device_map.iter() {
content_grouped
.entry(content.json().get())
.or_default()
.entry(user_id.clone())
.or_default()
.push(device_id.to_owned());
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is a bit trippy, you are reversing the mapping from user -> device -> content to content -> user -> device.

As far as I understand this is an optimization so we can use the function that encrypts the same content for multiple devices.

Could you document this fact, it isn't immediately obvious why this is needed.

Renaming the variable to content_to_recipients_map or some similar more obvious name might help as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


response.map_err(Into::into)
// Encrypt and send
Copy link
Contributor

@poljar poljar Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a perfect place where we could introduce a helper function.

This is getting a bit long I would say.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 389 to 394
client.encryption().get_user_devices(&user_id).await?;
let devices = user_devices.devices();
devices.for_each(|device| {
device_set.insert((
device.user_id().to_owned(),
device.device_id().to_owned(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit weird, you are doing the following:

  1. Get all the devices of the user
  2. Record the user/device id
  3. Throw the devices away
  4. Iterate through each recorded user/device id and load each device individually from the store.

It seems to me that it would be much better to:

  1. Get all the devices of a user
  2. If an alias was used, you have your set.
  3. If a device IDs were used, filter out any device that isn't part of the recipient set.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update here 159a7a1

I haven't found the simple way of finding failure like you suggested let failures = recipient_list - recipient_devices, I tried something else

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still feels pretty suboptimal:

  1. You fetch all devices for each DeviceId entry in the list.
  2. You collect a bunch of duplicates, which wastes memory until...
  3. You deduplicate the list of recipient devices.

What I had in mind would be something like the following diff:

Commit ID: d205fe05c3577be6ca56d4a994dccbc00be52b7b
Change ID: unuvrlxqtqmnuptxnkxukwuoxmknkktl
Author   : Damir Jelić <[email protected]> (2025-06-26 12:22:21)
Committer: Damir Jelić <[email protected]> (2025-06-26 13:05:20)

    (no description set)

diff --git a/crates/matrix-sdk/src/widget/matrix.rs b/crates/matrix-sdk/src/widget/matrix.rs
index 8fd210baa7..8b1df37c11 100644
--- a/crates/matrix-sdk/src/widget/matrix.rs
+++ b/crates/matrix-sdk/src/widget/matrix.rs
@@ -15,7 +15,7 @@
 //! Matrix driver implementation that exposes Matrix functionality
 //! that is relevant for the widget API.
 
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
 
 use as_variant::as_variant;
 use matrix_sdk_base::deserialized_responses::{EncryptionInfo, RawAnySyncOrStrippedState};
@@ -436,37 +436,48 @@
         let client = self.room.client();
         let mut recipient_devices = Vec::<Device>::new();
 
-        for (user_id, device_id_or_alias) in user_to_list_of_device_id_or_all {
+        for (user_id, recipient_device_ids) in user_to_list_of_device_id_or_all {
             let user_devices = client.encryption().get_user_devices(&user_id).await?;
 
-            if device_id_or_alias.contains(&DeviceIdOrAllDevices::AllDevices) {
-                recipient_devices.extend(user_devices.devices());
+            let user_devices: Vec<Device> = if recipient_device_ids
+                .contains(&DeviceIdOrAllDevices::AllDevices)
+            {
+                // If the user wants to send to all devices there's nothing to filter and no
+                // need to inspect other entries in the users device list.
+                user_devices.devices().collect()
             } else {
-                recipient_devices.extend(user_devices.devices().filter(|d| {
-                    device_id_or_alias
-                        .contains(&DeviceIdOrAllDevices::DeviceId(d.device_id().to_owned()))
-                }));
-
-                let missing_devices = device_id_or_alias
-                    .iter()
-                    .filter_map(|d| {
-                        as_variant!(d, DeviceIdOrAllDevices::DeviceId)
-                            .map(|device_id| device_id.to_owned())
-                    })
-                    .filter(|device_id| user_devices.get(device_id).is_none())
-                    .collect::<Vec<_>>();
-
-                if !missing_devices.is_empty() {
-                    failures.entry(user_id).or_default().extend(missing_devices);
-                }
-            }
+                // If the user wants to send to only some devices, filter out any devices that
+                // aren't part of the recipient_device_ids list.
+                let filtered_devices = user_devices
+                    .devices()
+                    .map(|device| (device.device_id().to_owned(), device))
+                    .filter(|(device_id, _)| {
+                        recipient_device_ids
+                            .contains(&DeviceIdOrAllDevices::DeviceId(device_id.clone()))
+                    });
+
+                let (found_device_ids, devices): (BTreeSet<_>, Vec<_>) = filtered_devices.unzip();
+
+                let list_of_devices: BTreeSet<_> = recipient_device_ids
+                    .into_iter()
+                    .filter_map(|d| as_variant!(d, DeviceIdOrAllDevices::DeviceId))
+                    .collect();
+
+                // Let's now find any devices that are part of the recipient_device_ids list but
+                // were not found in our store.
+                // TODO: Do we want to do this for the case where all devices are part of the
+                // recipient list, in case someone specified all devices as well as some devices
+                // explicitly?
+                let missing_devices =
+                    list_of_devices.difference(&found_device_ids).map(|d| d.to_owned()).collect();
+                failures.insert(user_id, missing_devices);
+
+                devices
+            };
+
+            recipient_devices.extend(user_devices);
         }
 
-        // The user-provided list of devices may contain duplicates, ensure no
-        // duplicates are sent.
-        recipient_devices
-            .dedup_by(|da, db| da.user_id().eq(db.user_id()) && da.device_id().eq(db.device_id()));
-
         if !recipient_devices.is_empty() {
             // need to group by content
             let encrypt_and_send_failures = client

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found the simple way of finding failure like you suggested let failures = recipient_list - recipient_devices, I tried something else

Sorry, I meant BTreeSet::difference() but couldn't remember the name of the method.

Comment on lines 403 to 412
for (u, d) in device_set {
let found_device = client.encryption().get_device(&u, &d).await?;
let Some(device) = found_device else {
warn!("Device {} not found for user {}", d, u);
// device not found, skip it and log
failures.entry(u).or_default().push(d);
continue;
};
recipients.push(device);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you have all the recipient devices collected, getting the failures would be a simple set operation.

let failures = recipient_list - recipient_devices

@BillCarsonFr BillCarsonFr requested a review from poljar June 25, 2025 13:16
Copy link
Contributor

@poljar poljar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not quite to my liking. I left a diff that I think makes things clearer and doesn't load all devices for each user multiple times.

Hopefully it helps out a bit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support sending to_device messages in the rust sdk widget driver. (MSC3819)
2 participants