973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 1 | # macltok request, pre-share room keys before send |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 2 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 3 | ## Problem |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 4 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 5 | Sending into an encrypted room is slow in malice (and Element X), and under federation lag it can take over a minute. Clients on other SDKs feel instant because they do the key work before the user presses send. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 6 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 7 | matrix-sdk 0.18 does all of it inline on the send path. `SendRawMessageLikeEvent::into_future` (`matrix-sdk-0.18.0/src/room/futures.rs:190-245`) calls the private `ensure_room_encryption_ready` (`futures.rs:523-537`), which runs, in the room's single send-queue worker and before the `/send` PUT |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 8 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 9 | 1. `sync_members()` if `!are_members_synced()` |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 10 | 2. `query_keys_for_untracked_or_dirty_users()`, a `/keys/query` that the homeserver fans out over federation |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 11 | 3. `preshare_room_key()` (`room/mod.rs:2396-2431`), which claims one-time keys (`/keys/claim`, 10s server timeout, also federated) and then sends the megolm key to every device as to-device messages, awaiting each |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 12 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 13 | Every request uses the default 30s timeout, and the send queue retries with `short_retry` (3 attempts, exponential backoff). A slow remote server stalls the send and everything queued behind it in that room. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 14 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 15 | For comparison, matrix-dart-sdk calls `prepareOutboundGroupSession` unawaited from `Client.setTyping`, so FluffyChat has usually finished steps 1 to 3 by the time the user hits send. matrix-js-sdk exposes `CryptoApi.prepareToEncrypt(room)` for the same purpose. matrix-sdk has the primitive but keeps it private, with this at `room/mod.rs:2392` |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 16 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 17 | ```rust |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 18 | // TODO: expose this publicly so people can pre-share a group session if |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 19 | // e.g. a user starts to type a message for a room. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 20 | ``` |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 21 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 22 | ## What is reachable today |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 23 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 24 | Checked against the 0.18 sources. Only the first step is public. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 25 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 26 | | step | public from `matrix_sdk`? | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 27 | |---|---| |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 28 | | `Room::sync_members`, `are_members_synced` | yes | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 29 | | `Room::query_keys_for_untracked_or_dirty_users` | no, private. `Encryption::request_user_identity(user_id)` does a per-user `/keys/query` | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 30 | | `Client::claim_one_time_keys` | no, `pub(crate)` | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 31 | | `Room::share_room_key`, `Room::preshare_room_key` | no, private. `OlmMachine::share_room_key` is pub but `Client::olm_machine()` is `pub(crate)` | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 32 | | `ensure_room_encryption_ready` | no, private free fn | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 33 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 34 | There is no public encrypt-without-send path either. Every caller of `ensure_room_encryption_ready` goes on to send. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 35 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 36 | ## Request |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 37 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 38 | Expose a command that runs the send path's encryption prep without sending anything, fire and forget, deduplicated per room. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 39 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 40 | ```rust |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 41 | impl ClientHandle { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 42 | /// no-op for unencrypted rooms, coalesces with an in-flight prepare for the same room |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 43 | pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error>; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 44 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 45 | ``` |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 46 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 47 | malice would call it when the user starts typing in a room (alongside a typing notice) and probably when a room is opened. A later send then finds members synced, device lists fresh and the outbound session already shared, so only the `/send` PUT remains. Correctness is unchanged, since the send path still runs `ensure_room_encryption_ready` and redoes whatever went stale in between (a member joining after the prepare, say). |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 48 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 49 | Suggested route, in order of preference |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 50 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 51 | 1. Upstream a small matrix-rust-sdk PR making `Room::preshare_room_key` public, or adding `pub async fn Room::prepare_encryption(&self)` that wraps `ensure_room_encryption_ready`. The TODO invites exactly this. Until it is released macltok could carry it via a `[patch.crates-io]` git fork, if that fits its dependency policy. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 52 | 2. A partial warm-up using only public API while the PR is pending. `sync_members()` when not synced, then `request_user_identity` for members whose device lists are unknown, capped (members can be many and each query is a federated round trip). This removes step 1 and the `/keys/query` part of step 2 from the send path, which is the part federation lag hurts most. Steps 3 (`/keys/claim` and the to-device share) stay on the send path. It does not know which users are dirty (only `tracked_users()` is public), so it can over-query. Worth it only if option 1 will take a while. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 53 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 54 | malice will pair it with the existing `ClientHandle::set_typing(room_id, bool)` (`transport.rs:357`), so no new typing API is needed. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 55 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 56 | ## Not requested |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 57 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 58 | A "send without waiting for fresh device lists" mode. It would encrypt to stale device lists, so new members and new devices miss the key and see undecryptable messages. Pre-sharing gives the same latency without that. |