From 95eea9b36b2f4192fed009edefd8553c24097061 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:51:01 +0000 Subject: [PATCH 1/7] feat(sdk): high-level dehydrated-devices manager (MSC3814). Adds `Encryption::dehydrated_devices()` returning a manager with `is_supported`, `create`, `rehydrate`, `delete`, `start`, and `stop`, plus a Secret Storage round trip for the pickle key, a broadcast event channel for lifecycle observability, and a weekly rotation task. The module is gated behind `e2e-encryption` and follows the matrix-js-sdk `DehydratedDeviceManager` shape so applications can adopt MSC3814 without writing the wire-glue themselves. Signed-off-by: Jason Volk --- .../src/encryption/dehydrated_devices.rs | 792 ++++++++++++++++++ crates/matrix-sdk/src/encryption/mod.rs | 17 + 2 files changed, 809 insertions(+) create mode 100644 crates/matrix-sdk/src/encryption/dehydrated_devices.rs diff --git a/crates/matrix-sdk/src/encryption/dehydrated_devices.rs b/crates/matrix-sdk/src/encryption/dehydrated_devices.rs new file mode 100644 index 00000000000..a9f57090805 --- /dev/null +++ b/crates/matrix-sdk/src/encryption/dehydrated_devices.rs @@ -0,0 +1,792 @@ +// Copyright 2026 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. + +//! High-level interface for [Dehydrated Devices] ([MSC3814]). +//! +//! A dehydrated device is a virtual device the homeserver keeps on the user's +//! behalf while no live device is online. Senders can encrypt to it using the +//! same Olm session establishment as for any other device. When a new device +//! comes online it rehydrates: it pulls the private parts of the virtual +//! device back down, decrypts them with a pickle key, drains the queued +//! to-device events, and imports the room keys they carry. +//! +//! # Lifecycle +//! +//! 1. `is_supported`: cheap probe of the homeserver. +//! 2. `create`: build a fresh dehydrated device and upload it. The pickle key +//! is supplied by the caller; storage and rotation of the pickle key are an +//! application concern. +//! 3. `rehydrate`: pull the existing dehydrated device, decrypt with the pickle +//! key, absorb queued to-device events, and delete the device. +//! 4. `delete`: remove the current dehydrated device without rehydrating. +//! +//! # Example +//! +//! ```no_run +//! # use matrix_sdk::Client; +//! # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey; +//! # async fn example(client: Client) -> anyhow::Result<()> { +//! let dehydrated = client.encryption().dehydrated_devices(); +//! +//! if !dehydrated.is_supported().await? { +//! return Ok(()); +//! } +//! +//! let pickle_key = DehydratedDeviceKey::new(); +//! +//! dehydrated.rehydrate(&pickle_key).await?; +//! dehydrated.create(None, &pickle_key).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! [Dehydrated Devices]: https://spec.matrix.org/unstable/client-server-api/#dehydrated-devices +//! [MSC3814]: https://github.com/matrix-org/matrix-spec-proposals/pull/3814 + +use std::time::Duration; + +use futures_core::Stream; +use matrix_sdk_base::crypto::{ + DecryptionSettings, OlmError, TrustRequirement, + dehydrated_devices::{DehydrationError, RehydratedDevice}, + store::types::DehydratedDeviceKey, + vodozemac::base64_decode, +}; +use matrix_sdk_common::{executor::spawn, locks::Mutex as StdMutex, sleep::sleep}; +use ruma::{ + OwnedDeviceId, + api::{ + client::dehydrated_device::{ + DehydratedDeviceData, delete_dehydrated_device, get_dehydrated_device, get_events, + }, + error::ErrorKind, + }, + events::secret::request::SecretName, + serde::Raw, +}; +use thiserror::Error; +use tokio::sync::broadcast; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; +use tracing::{debug, info, instrument, trace, warn}; +use zeroize::Zeroizing; + +use crate::{ + Client, HttpError, + client::WeakClient, + encryption::{CryptoStoreError, secret_storage::SecretStore}, + executor::JoinHandle, +}; + +/// The default display name uploaded for a freshly created dehydrated device. +const DEFAULT_DEVICE_DISPLAY_NAME: &str = "Dehydrated device"; + +/// The name used to store the dehydrated-device pickle key in Secret Storage. +/// +/// MSC3814 reserves `m.dehydrated_device` for the stable name; this is the +/// unstable equivalent the implementation will publish until the MSC +/// stabilizes. +const PICKLE_KEY_SECRET_NAME: &str = "org.matrix.msc3814"; + +/// How often [`DehydratedDevices::start`] rotates the dehydrated device. +/// +/// Set to one week, matching matrix-js-sdk's `DEHYDRATION_INTERVAL`. +pub const DEHYDRATION_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// Errors that can occur while managing dehydrated devices. +#[derive(Debug, Error)] +pub enum DehydratedDeviceError { + /// The HTTP request to the homeserver failed. + #[error(transparent)] + Http(#[from] HttpError), + + /// The cryptographic operation on the dehydrated device failed. + #[error(transparent)] + Crypto(#[from] DehydrationError), + + /// Importing room keys from a rehydrated device's to-device events + /// failed. + #[error(transparent)] + Olm(#[from] OlmError), + + /// The crypto store could not be accessed. + #[error(transparent)] + Store(#[from] CryptoStoreError), + + /// Reading or writing a secret to Secret Storage failed. + #[error(transparent)] + SecretStorage(#[from] crate::encryption::secret_storage::SecretStorageError), + + /// The pickle key stored in Secret Storage was not valid base64. + #[error("the dehydrated-device pickle key in Secret Storage is not valid base64: {0}")] + PickleKeyDecode(#[from] vodozemac::Base64DecodeError), + + /// The client is not logged in; the Olm machine is not available. + #[error("the client is not logged in")] + NotLoggedIn, +} + +/// Return a [`SecretName`] for the dehydrated-device pickle key entry. +fn pickle_key_secret_name() -> SecretName { + SecretName::from(PICKLE_KEY_SECRET_NAME) +} + +/// Lifecycle events emitted by [`DehydratedDevices`]. +/// +/// Subscribe with [`DehydratedDevices::events`] to observe creation, +/// rehydration progress, and rotation outcomes. The set of variants is a +/// superset of matrix-js-sdk's `CryptoEvent::Dehydrated*` and +/// `CryptoEvent::Rehydration*`: [`Self::Created`], [`Self::Uploaded`], +/// [`Self::RehydrationStarted`], [`Self::RehydrationProgress`], and +/// [`Self::RehydrationError`] mirror js-sdk one for one. The remaining +/// variants are Rust-side extensions retained because callers asked for +/// them in code review: [`Self::Deleted`] signals the post-rehydration +/// delete, [`Self::RehydrationCompleted`] carries the imported counts so +/// the caller does not have to fold over progress events, and +/// [`Self::RotationError`] surfaces background rotation failures the +/// task otherwise swallows. +#[derive(Clone, Debug)] +pub enum DehydratedDeviceEvent { + /// A fresh dehydrated device was constructed in the local crypto + /// store, before the upload PUT. + Created { + /// Device ID assigned to the new dehydrated device. + device_id: OwnedDeviceId, + }, + /// The dehydrated device announced by the preceding + /// [`Self::Created`] event was accepted by the homeserver. + Uploaded { + /// Device ID of the dehydrated device now visible on the server. + device_id: OwnedDeviceId, + }, + /// The dehydrated device currently on the server was deleted. + Deleted, + /// A pickle key was cached in the local crypto store. + KeyCached, + /// Rehydration of a dehydrated device began. + RehydrationStarted { + /// Device ID of the dehydrated device being rehydrated. + device_id: OwnedDeviceId, + }, + /// A batch of to-device events has been imported during rehydration. + RehydrationProgress { + /// Cumulative number of room keys imported so far. + room_keys_imported: usize, + /// Cumulative number of to-device events processed so far. + to_device_events: usize, + }, + /// Rehydration finished successfully. + RehydrationCompleted { + /// Device ID of the rehydrated device. + device_id: OwnedDeviceId, + /// Total number of room keys imported. + room_keys_imported: usize, + /// Total number of to-device events processed. + to_device_events: usize, + }, + /// Rehydration failed before it could complete. + RehydrationError { + /// Human-readable description of the failure. + error: String, + }, + /// A scheduled rotation tick failed; the rotation task remains + /// scheduled and will retry at the next tick. + RotationError { + /// Human-readable description of the failure. + error: String, + }, +} + +/// Options for [`DehydratedDevices::start`]. +#[derive(Clone, Debug)] +pub struct StartDehydrationOpts { + /// Force generation of a fresh random pickle key on start, replacing + /// any existing entry in Secret Storage and the local cache. + pub create_new_key: bool, + /// Whether to attempt to rehydrate the existing dehydrated device, if + /// any, before creating the next one. Defaults to `true`. + pub rehydrate: bool, + /// If `true`, [`DehydratedDevices::start`] becomes a no-op when no + /// pickle key is cached locally. Useful for opportunistic restart on + /// a freshly opened client without forcing a Secret Storage unlock. + pub only_if_key_cached: bool, +} + +impl Default for StartDehydrationOpts { + fn default() -> Self { + Self { create_new_key: false, rehydrate: true, only_if_key_cached: false } + } +} + +/// Process-wide state for the dehydrated-devices manager. +/// +/// Held inside [`crate::encryption::EncryptionData`] so the event sender +/// and any in-flight rotation task survive across +/// `Client::encryption().dehydrated_devices()` calls. +pub(crate) struct DehydratedDevicesState { + event_sender: broadcast::Sender, + rotation_task: StdMutex>, + /// The ID of the dehydrated device most recently uploaded by this + /// process. Used by [`DehydratedDevices::rehydrate`] to detect a + /// server that serves a different (potentially attacker-replayed) + /// device id within the same session. Lost on restart; cross-session + /// continuity is not enforced. + last_uploaded_device_id: StdMutex>, +} + +impl Default for DehydratedDevicesState { + fn default() -> Self { + let (event_sender, _) = broadcast::channel(32); + Self { + event_sender, + rotation_task: StdMutex::new(None), + last_uploaded_device_id: StdMutex::new(None), + } + } +} + +/// Whether the rotation timer should keep running after a tick. +enum RotationTickOutcome { + /// The rotation timer should remain scheduled and try again at the next + /// interval. Used both for successful ticks and for recoverable HTTP or + /// store errors. + Continue, + /// The rotation timer must abort. Used when the cached pickle key is + /// gone; restarting the timer requires a fresh [`DehydratedDevices::start`] + /// call. + Halt, +} + +/// The background task that re-creates the dehydrated device on a fixed +/// interval. Aborted on drop. +struct DehydratedDeviceRotationTask { + #[cfg_attr(target_family = "wasm", allow(dead_code))] + join_handle: JoinHandle<()>, +} + +impl Drop for DehydratedDeviceRotationTask { + fn drop(&mut self) { + #[cfg(not(target_family = "wasm"))] + self.join_handle.abort(); + } +} + +/// High-level handle returned by +/// [`Encryption::dehydrated_devices`](crate::encryption::Encryption::dehydrated_devices). +#[derive(Debug, Clone)] +pub struct DehydratedDevices { + pub(super) client: Client, +} + +/// The dehydrated device the server currently holds on the user's behalf. +struct DownloadedDevice { + device_id: OwnedDeviceId, + device_data: Raw, +} + +impl DehydratedDevices { + /// Subscribe to [`DehydratedDeviceEvent`]s. + /// + /// Each call returns a fresh stream. If a subscriber is slow enough to + /// fall behind the channel's buffer, it receives a + /// [`BroadcastStreamRecvError`] reporting the number of skipped events + /// and the stream continues from the most recent event. + pub fn events( + &self, + ) -> impl Stream> + use<> { + BroadcastStream::new(self.state().event_sender.subscribe()) + } + + fn state(&self) -> &DehydratedDevicesState { + &self.client.inner.e2ee.dehydrated_devices_state + } + + fn emit(&self, event: DehydratedDeviceEvent) { + // A send failure means there are no subscribers; that is fine. + let _ = self.state().event_sender.send(event); + } + + /// Return whether the homeserver advertises dehydrated-device support. + /// + /// Probes by issuing `GET /dehydrated_device` and inspecting the errcode + /// of the response: + /// + /// - `M_UNRECOGNIZED` means the server does not understand the endpoint. + /// - `M_NOT_FOUND` or a successful response means the server understands + /// the endpoint (whether or not the user currently has a dehydrated + /// device on file). + /// + /// Any other transport or API failure is propagated. + #[instrument(skip_all)] + pub async fn is_supported(&self) -> Result { + let request = get_dehydrated_device::unstable::Request::new(); + match self.client.send(request).await { + Ok(_) => Ok(true), + Err(e) => match e.client_api_error_kind() { + Some(ErrorKind::Unrecognized) => Ok(false), + Some(ErrorKind::NotFound) => Ok(true), + _ => Err(e.into()), + }, + } + } + + /// Create a fresh dehydrated device and upload it to the homeserver. + /// + /// The pickle key is used by [vodozemac] to encrypt the private parts of + /// the device. The application is responsible for safely storing the + /// pickle key (typically in Secret Storage so future sessions can + /// rehydrate the device). + /// + /// # Arguments + /// + /// * `display_name` - Optional human-readable name uploaded as the + /// dehydrated device's `initial_device_display_name`. Defaults to + /// `"Dehydrated device"` to match the existing [matrix-js-sdk] behavior. + /// * `pickle_key` - 32-byte key used to encrypt the dehydrated device. + /// + /// [vodozemac]: https://docs.rs/vodozemac/ + /// [matrix-js-sdk]: https://github.com/matrix-org/matrix-js-sdk + #[instrument(skip_all)] + pub async fn create( + &self, + display_name: Option<&str>, + pickle_key: &DehydratedDeviceKey, + ) -> Result { + let olm = self.client.olm_machine().await; + let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?; + + debug!("Creating a new dehydrated device in the crypto store"); + let dehydrated_device = machine.dehydrated_devices().create().await?; + + let display_name = display_name.unwrap_or(DEFAULT_DEVICE_DISPLAY_NAME); + let request = + dehydrated_device.keys_for_upload(display_name.to_owned(), pickle_key).await?; + let device_id = request.device_id.clone(); + self.emit(DehydratedDeviceEvent::Created { device_id: device_id.clone() }); + + debug!(?device_id, "Uploading dehydrated device to the homeserver"); + self.client.send(request).await?; + info!(?device_id, "Successfully uploaded dehydrated device"); + + *self.state().last_uploaded_device_id.lock() = Some(device_id.clone()); + self.emit(DehydratedDeviceEvent::Uploaded { device_id: device_id.clone() }); + Ok(device_id) + } + + /// Rehydrate the dehydrated device currently on the server, if any. + /// + /// Downloads the dehydrated device, decrypts it with `pickle_key`, + /// drains all queued to-device events to import their room keys, and + /// finally deletes the device from the server. + /// + /// Returns `Ok(false)` if the server reports no dehydrated device + /// (`M_NOT_FOUND`) or does not implement the endpoint + /// (`M_UNRECOGNIZED`). Returns `Ok(true)` once the rehydration cycle + /// has completed end to end. + #[instrument(skip_all)] + pub async fn rehydrate( + &self, + pickle_key: &DehydratedDeviceKey, + ) -> Result { + let Some(downloaded) = self.download_device().await? else { return Ok(false) }; + info!(device_id = ?downloaded.device_id, "Dehydrated device found"); + + // Within this process, the server should serve back whichever id we + // last uploaded. A mismatch can indicate a stale or replayed payload; + // warn so the application can decide to drop or quarantine the + // imported keys. Cross-restart continuity is not enforced because the + // last-uploaded id is not persisted to the crypto store. + if let Some(expected) = self.state().last_uploaded_device_id.lock().clone() + && expected != downloaded.device_id + { + warn!( + ?expected, + got = ?downloaded.device_id, + "Server returned a different dehydrated-device id than the one we last uploaded; continuing but the payload may be stale" + ); + } + + self.emit(DehydratedDeviceEvent::RehydrationStarted { + device_id: downloaded.device_id.clone(), + }); + + let rehydrated = self.rehydrate_device(&downloaded, pickle_key).await?; + let (room_keys_imported, to_device_events) = + self.absorb_events(&downloaded.device_id, &rehydrated).await?; + + self.emit(DehydratedDeviceEvent::RehydrationCompleted { + device_id: downloaded.device_id.clone(), + room_keys_imported, + to_device_events, + }); + + // Key import already succeeded; if the post-drain delete fails, log + // it but do not let the failure masquerade as a rehydration error. + // The next create() call will replace the device anyway. + if let Err(e) = self.delete().await { + warn!(device_id = ?downloaded.device_id, error = %e, "Post-rehydration delete failed; the next rotation will replace the device"); + } + + Ok(true) + } + + /// Cache the pickle key in the local crypto store. + /// + /// Subsequent rehydration attempts can then resolve the key from the + /// cache without an account-data round-trip. Equivalent to + /// matrix-js-sdk's `DehydratedDeviceManager.cacheKey`. + #[instrument(skip_all)] + pub(crate) async fn cache_key( + &self, + pickle_key: &DehydratedDeviceKey, + ) -> Result<(), DehydratedDeviceError> { + let olm = self.client.olm_machine().await; + let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?; + + machine.dehydrated_devices().save_dehydrated_device_pickle_key(pickle_key).await?; + self.emit(DehydratedDeviceEvent::KeyCached); + Ok(()) + } + + /// Return the pickle key currently cached in the local crypto store. + /// + /// `Ok(None)` if no key has been cached. The returned key matches the + /// last value persisted via [`cache_key`](Self::cache_key) or + /// [`reset_key`](Self::reset_key); it is not fetched from Secret Storage. + #[instrument(skip_all)] + pub(crate) async fn cached_key( + &self, + ) -> Result, DehydratedDeviceError> { + let olm = self.client.olm_machine().await; + let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?; + + Ok(machine.dehydrated_devices().get_dehydrated_device_pickle_key().await?) + } + + /// Return whether the pickle key is stored in the given Secret Storage. + /// + /// The key is looked up by the account-data event type + /// `org.matrix.msc3814` (the unstable name reserved by MSC3814). + pub async fn is_key_stored( + &self, + secret_store: &SecretStore, + ) -> Result { + Ok(secret_store.get_secret(pickle_key_secret_name()).await?.is_some()) + } + + /// Generate a new random pickle key, persist it in Secret Storage, and + /// cache it in the local crypto store. + /// + /// The previous key (if any) is overwritten in both places. Any + /// dehydrated device that was encrypted with the previous key becomes + /// unrehydratable until rotated. + #[instrument(skip_all)] + pub async fn reset_key( + &self, + secret_store: &SecretStore, + ) -> Result { + let key = DehydratedDeviceKey::new(); + secret_store.put_secret(pickle_key_secret_name(), &key.to_base64()).await?; + self.cache_key(&key).await?; + Ok(key) + } + + /// Resolve the pickle key. + /// + /// Looks for the key in this order: + /// + /// 1. The local crypto-store cache. + /// 2. The provided Secret Storage account-data entry. A successfully + /// fetched key is written back to the cache. + /// 3. If `create_if_missing`, a fresh random key is generated, stored, and + /// cached. + /// + /// Returns `Ok(None)` only when the key is absent from both sources and + /// `create_if_missing` is `false`. + #[instrument(skip_all)] + pub(crate) async fn load_key( + &self, + secret_store: &SecretStore, + create_if_missing: bool, + ) -> Result, DehydratedDeviceError> { + if let Some(cached) = self.cached_key().await? { + return Ok(Some(cached)); + } + + let Some(base64) = secret_store.get_secret(pickle_key_secret_name()).await? else { + return if create_if_missing { + Ok(Some(self.reset_key(secret_store).await?)) + } else { + Ok(None) + }; + }; + + let bytes = Zeroizing::new(base64_decode(&base64)?); + let key = DehydratedDeviceKey::from_slice(&bytes)?; + self.cache_key(&key).await?; + Ok(Some(key)) + } + + /// Start using dehydrated devices for this client. + /// + /// The caller is expected to have unlocked Secret Storage and bootstrapped + /// cross-signing before this call; otherwise the underlying account-data + /// reads and writes will fail mid-flight. + /// + /// Mirrors matrix-js-sdk's `DehydratedDeviceManager.start`: + /// + /// 1. If `opts.only_if_key_cached` is set, return early when no pickle key + /// is cached locally. + /// 2. Stop any previously scheduled rotation. + /// 3. If `opts.rehydrate`, attempt to rehydrate the existing dehydrated + /// device. Failures are logged and emitted as + /// [`DehydratedDeviceEvent::RehydrationError`] but do not abort `start`. + /// 4. If `opts.create_new_key` *and* the rehydration step succeeded (or was + /// skipped), replace the pickle key in Secret Storage with a fresh + /// random one. A failed rehydration suppresses the reset so the stored + /// key can still recover the existing dehydrated device on another + /// client. + /// 5. Create a new dehydrated device now and schedule rotation every + /// [`DEHYDRATION_INTERVAL`]. + /// + /// The rotation task resolves the pickle key from the local crypto + /// store on each tick. The local cache is the only key source available + /// to the task because reopening Secret Storage requires the recovery + /// key, which is not retained. If the cache is cleared while the task + /// is scheduled, the task emits + /// [`DehydratedDeviceEvent::RotationError`] and stops; restart it with + /// a fresh [`Self::start`] call once the key is available again. + /// Per-tick HTTP failures emit + /// [`DehydratedDeviceEvent::RotationError`] without aborting the + /// schedule. + #[instrument(skip_all)] + pub async fn start( + &self, + secret_store: &SecretStore, + opts: StartDehydrationOpts, + ) -> Result<(), DehydratedDeviceError> { + if opts.only_if_key_cached && self.cached_key().await?.is_none() { + return Ok(()); + } + + self.stop(); + + let mut rehydrate_failed = false; + if opts.rehydrate + && let Some(key) = self.load_key(secret_store, false).await? + && let Err(e) = self.rehydrate(&key).await + { + let msg = e.to_string(); + warn!(error = %e, "Rehydration failed during start; continuing"); + self.emit(DehydratedDeviceEvent::RehydrationError { error: msg }); + rehydrate_failed = true; + } + + // Refuse to clobber Secret Storage after a failed rehydration: the + // stored pickle key is the only one that can decrypt the existing + // dehydrated device, and overwriting it would discard that recovery + // chance for every other client signed in to this account. + if opts.create_new_key { + if rehydrate_failed { + warn!( + "Skipping pickle-key reset after failed rehydration to preserve the chance of recovering the existing dehydrated device on another client" + ); + } else { + self.reset_key(secret_store).await?; + } + } + + self.schedule_dehydration(secret_store).await + } + + /// Stop the scheduled dehydrated-device rotation, if any. + /// + /// Has no effect when no rotation is scheduled. Existing dehydrated + /// devices on the server are left in place; pair with + /// [`Self::delete`] to clean those up. + pub fn stop(&self) { + self.state().rotation_task.lock().take(); + } + + /// Create-and-upload the first dehydrated device now, then spawn the + /// rotation loop. + async fn schedule_dehydration( + &self, + secret_store: &SecretStore, + ) -> Result<(), DehydratedDeviceError> { + let key = self + .load_key(secret_store, true) + .await? + .expect("load_key(create_if_missing=true) always yields a key"); + self.create(None, &key).await?; + + let weak_client = WeakClient::from_client(&self.client); + let join_handle = spawn(async move { + loop { + sleep(DEHYDRATION_INTERVAL).await; + + let Some(client) = weak_client.get() else { + debug!("Client dropped; halting dehydrated-device rotation"); + return; + }; + + match client.encryption().dehydrated_devices().rotate_tick().await { + RotationTickOutcome::Continue => {} + RotationTickOutcome::Halt => return, + } + } + }); + + *self.state().rotation_task.lock() = Some(DehydratedDeviceRotationTask { join_handle }); + Ok(()) + } + + /// One iteration of the rotation timer: resolve the cached pickle key, + /// upload a fresh dehydrated device, and report whether the timer should + /// keep running. + async fn rotate_tick(&self) -> RotationTickOutcome { + let key = match self.cached_key().await { + Ok(Some(key)) => key, + Ok(None) => { + let msg = "no cached pickle key for dehydrated-device rotation".to_owned(); + warn!("{msg}; halting timer until start() is called again"); + self.emit(DehydratedDeviceEvent::RotationError { error: msg }); + return RotationTickOutcome::Halt; + } + Err(e) => { + let msg = e.to_string(); + warn!(error = %e, "Failed to load cached pickle key for rotation"); + self.emit(DehydratedDeviceEvent::RotationError { error: msg }); + return RotationTickOutcome::Continue; + } + }; + + if let Err(e) = self.create(None, &key).await { + let msg = e.to_string(); + warn!(error = msg, "Failed to rotate dehydrated device"); + self.emit(DehydratedDeviceEvent::RotationError { error: msg }); + } + RotationTickOutcome::Continue + } + + /// Delete the current dehydrated device, if one exists. + /// + /// Also stops any scheduled rotation, so the next tick will not + /// immediately recreate the device the caller just asked to remove. + /// + /// Returns `Ok(())` silently if no dehydrated device is on the server or + /// the server does not implement the endpoint, matching the + /// matrix-js-sdk's behavior. + #[instrument(skip_all)] + pub async fn delete(&self) -> Result<(), DehydratedDeviceError> { + self.stop(); + let request = delete_dehydrated_device::unstable::Request::new(); + match self.client.send(request).await { + Ok(_) => { + self.emit(DehydratedDeviceEvent::Deleted); + Ok(()) + } + Err(e) => match e.client_api_error_kind() { + Some(ErrorKind::Unrecognized) | Some(ErrorKind::NotFound) => Ok(()), + _ => Err(e.into()), + }, + } + } + + /// Fetch the dehydrated device payload from the server. + /// + /// Returns `Ok(None)` if the server reports `M_NOT_FOUND` or + /// `M_UNRECOGNIZED`. + async fn download_device(&self) -> Result, DehydratedDeviceError> { + let request = get_dehydrated_device::unstable::Request::new(); + match self.client.send(request).await { + Ok(response) => Ok(Some(DownloadedDevice { + device_id: response.device_id, + device_data: response.device_data, + })), + Err(e) => match e.client_api_error_kind() { + Some(ErrorKind::NotFound) | Some(ErrorKind::Unrecognized) => Ok(None), + _ => Err(e.into()), + }, + } + } + + /// Decrypt the downloaded device and stand up a [`RehydratedDevice`]. + async fn rehydrate_device( + &self, + downloaded: &DownloadedDevice, + pickle_key: &DehydratedDeviceKey, + ) -> Result { + let olm = self.client.olm_machine().await; + let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?; + + Ok(machine + .dehydrated_devices() + .rehydrate(pickle_key, &downloaded.device_id, downloaded.device_data.clone()) + .await?) + } + + /// Drain every queued to-device event from the dehydrated device's + /// server-side buffer, feeding each batch through the rehydrated + /// machine so the room keys are imported. Returns + /// `(room_keys_imported, to_device_events_processed)`. + async fn absorb_events( + &self, + device_id: &OwnedDeviceId, + rehydrated: &RehydratedDevice, + ) -> Result<(usize, usize), DehydratedDeviceError> { + let settings = + DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }; + + let mut next_batch: Option = None; + let mut to_device_count: usize = 0; + let mut room_key_count: usize = 0; + + loop { + let mut request = get_events::unstable::Request::new(device_id.clone()); + request.next_batch.clone_from(&next_batch); + + let response = self.client.send(request).await?; + if response.events.is_empty() { + break; + } + + to_device_count += response.events.len(); + let imported = rehydrated.receive_events(response.events, &settings).await?; + room_key_count += imported.len(); + trace!(to_device_count, room_key_count, "Absorbed a batch of to-device events"); + self.emit(DehydratedDeviceEvent::RehydrationProgress { + room_keys_imported: room_key_count, + to_device_events: to_device_count, + }); + + // Defensive guard against a server that keeps returning the + // same cursor or stops returning one altogether. matrix-js-sdk + // does not have this; added here as a safety net. + match response.next_batch { + None => break, + Some(ref token) if Some(token) == next_batch.as_ref() => { + warn!( + ?next_batch, + "Server returned the same next_batch twice; aborting to avoid an infinite loop" + ); + break; + } + Some(token) => next_batch = Some(token), + } + } + + info!(to_device_count, room_key_count, "Drained dehydrated device to-device queue"); + Ok((room_key_count, to_device_count)) + } +} diff --git a/crates/matrix-sdk/src/encryption/mod.rs b/crates/matrix-sdk/src/encryption/mod.rs index 5cd1eac28c7..353cedd3742 100644 --- a/crates/matrix-sdk/src/encryption/mod.rs +++ b/crates/matrix-sdk/src/encryption/mod.rs @@ -105,6 +105,7 @@ use crate::{ }; pub mod backups; +pub mod dehydrated_devices; pub mod futures; pub mod identities; pub mod recovery; @@ -203,6 +204,10 @@ pub(crate) struct EncryptionData { /// All state related to secret storage recovery. pub recovery_state: SharedObservable, + + /// State for the dehydrated-devices manager (event channel, scheduled + /// rotation task). + pub dehydrated_devices_state: dehydrated_devices::DehydratedDevicesState, } impl EncryptionData { @@ -213,6 +218,7 @@ impl EncryptionData { tasks: StdMutex::new(Default::default()), backup_state: Default::default(), recovery_state: Default::default(), + dehydrated_devices_state: Default::default(), } } @@ -1797,6 +1803,17 @@ impl Encryption { Recovery { client: self.client.to_owned() } } + /// Get the dehydrated-devices manager of the client. + /// + /// A dehydrated device is a virtual device that the homeserver holds on + /// the user's behalf and that can receive end-to-end encrypted to-device + /// events while the user is offline. See the + /// [`dehydrated_devices`] module + /// for the full lifecycle and an example. + pub fn dehydrated_devices(&self) -> dehydrated_devices::DehydratedDevices { + dehydrated_devices::DehydratedDevices { client: self.client.to_owned() } + } + /// Enables the crypto-store cross-process lock. /// /// This may be required if there are multiple processes that may do writes From 7875d1289d6ab2030b418b309f15e3c7f40d1cbb Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:51:35 +0000 Subject: [PATCH 2/7] doc(sdk): add usage examples to every public dehydrated-device method. Each public method on `DehydratedDevices` now carries a runnable `no_run` example that shows the typical call site, so the docs.rs surface is discoverable without cross-referencing the integration tests. Signed-off-by: Jason Volk --- .../src/encryption/dehydrated_devices.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/matrix-sdk/src/encryption/dehydrated_devices.rs b/crates/matrix-sdk/src/encryption/dehydrated_devices.rs index a9f57090805..415e6f8cb44 100644 --- a/crates/matrix-sdk/src/encryption/dehydrated_devices.rs +++ b/crates/matrix-sdk/src/encryption/dehydrated_devices.rs @@ -301,6 +301,20 @@ impl DehydratedDevices { /// fall behind the channel's buffer, it receives a /// [`BroadcastStreamRecvError`] reporting the number of skipped events /// and the stream continues from the most recent event. + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::Client; + /// # use futures_util::StreamExt; + /// # async fn example(client: Client) -> anyhow::Result<()> { + /// let dehydrated = client.encryption().dehydrated_devices(); + /// let mut events = dehydrated.events(); + /// while let Some(Ok(event)) = events.next().await { + /// println!("dehydrated devices: {event:?}"); + /// } + /// # Ok(()) } + /// ``` pub fn events( &self, ) -> impl Stream> + use<> { @@ -354,6 +368,22 @@ impl DehydratedDevices { /// `"Dehydrated device"` to match the existing [matrix-js-sdk] behavior. /// * `pickle_key` - 32-byte key used to encrypt the dehydrated device. /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::Client; + /// # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey; + /// # async fn example(client: Client) -> anyhow::Result<()> { + /// let pickle_key = DehydratedDeviceKey::new(); + /// let device_id = client + /// .encryption() + /// .dehydrated_devices() + /// .create(Some("Offline catcher"), &pickle_key) + /// .await?; + /// println!("Uploaded dehydrated device {device_id}"); + /// # Ok(()) } + /// ``` + /// /// [vodozemac]: https://docs.rs/vodozemac/ /// [matrix-js-sdk]: https://github.com/matrix-org/matrix-js-sdk #[instrument(skip_all)] @@ -393,6 +423,21 @@ impl DehydratedDevices { /// (`M_NOT_FOUND`) or does not implement the endpoint /// (`M_UNRECOGNIZED`). Returns `Ok(true)` once the rehydration cycle /// has completed end to end. + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::Client; + /// # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey; + /// # async fn example(client: Client, pickle_key: DehydratedDeviceKey) + /// # -> anyhow::Result<()> { + /// let rehydrated = + /// client.encryption().dehydrated_devices().rehydrate(&pickle_key).await?; + /// if rehydrated { + /// println!("Caught up on offline room keys"); + /// } + /// # Ok(()) } + /// ``` #[instrument(skip_all)] pub async fn rehydrate( &self, @@ -477,6 +522,17 @@ impl DehydratedDevices { /// /// The key is looked up by the account-data event type /// `org.matrix.msc3814` (the unstable name reserved by MSC3814). + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore}; + /// # async fn example(client: Client, store: SecretStore) + /// # -> anyhow::Result<()> { + /// let stored = + /// client.encryption().dehydrated_devices().is_key_stored(&store).await?; + /// # Ok(()) } + /// ``` pub async fn is_key_stored( &self, secret_store: &SecretStore, @@ -490,6 +546,18 @@ impl DehydratedDevices { /// The previous key (if any) is overwritten in both places. Any /// dehydrated device that was encrypted with the previous key becomes /// unrehydratable until rotated. + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore}; + /// # async fn example(client: Client, store: SecretStore) + /// # -> anyhow::Result<()> { + /// let fresh_key = + /// client.encryption().dehydrated_devices().reset_key(&store).await?; + /// # let _ = fresh_key; + /// # Ok(()) } + /// ``` #[instrument(skip_all)] pub async fn reset_key( &self, @@ -569,6 +637,21 @@ impl DehydratedDevices { /// Per-tick HTTP failures emit /// [`DehydratedDeviceEvent::RotationError`] without aborting the /// schedule. + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore}; + /// # use matrix_sdk::encryption::dehydrated_devices::StartDehydrationOpts; + /// # async fn example(client: Client, store: SecretStore) + /// # -> anyhow::Result<()> { + /// client + /// .encryption() + /// .dehydrated_devices() + /// .start(&store, StartDehydrationOpts::default()) + /// .await?; + /// # Ok(()) } + /// ``` #[instrument(skip_all)] pub async fn start( &self, @@ -687,6 +770,15 @@ impl DehydratedDevices { /// Returns `Ok(())` silently if no dehydrated device is on the server or /// the server does not implement the endpoint, matching the /// matrix-js-sdk's behavior. + /// + /// # Example + /// + /// ```no_run + /// # use matrix_sdk::Client; + /// # async fn example(client: Client) -> anyhow::Result<()> { + /// client.encryption().dehydrated_devices().delete().await?; + /// # Ok(()) } + /// ``` #[instrument(skip_all)] pub async fn delete(&self) -> Result<(), DehydratedDeviceError> { self.stop(); From 2f2313f1758f40a4ebdd89125297b2175f941e0d Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:51:55 +0000 Subject: [PATCH 3/7] feat(sdk): MatrixMockServer helpers for MSC3814 dehydrated-device endpoints. Adds prebuilt mocks for `GET`, `PUT`, `DELETE /dehydrated_device` and `POST /dehydrated_device/{device_id}/events`, with helpers for the common success, `M_NOT_FOUND`, and `M_UNRECOGNIZED` responses and body matchers (`match_next_batch`, `match_missing_next_batch`) so integration tests can drive the full lifecycle without hand-rolling wiremock. Signed-off-by: Jason Volk --- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index dd835bb07e9..ad33bb3546b 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1160,6 +1160,47 @@ impl MatrixMockServer { self.mock_endpoint(mock, UploadCrossSigningSignaturesEndpoint).expect_default_access_token() } + /// Creates a prebuilt mock for the MSC3814 endpoint that fetches the + /// currently stored dehydrated device. + #[cfg(feature = "e2e-encryption")] + pub fn mock_get_dehydrated_device(&self) -> MockEndpoint<'_, GetDehydratedDeviceEndpoint> { + let mock = Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device$")); + self.mock_endpoint(mock, GetDehydratedDeviceEndpoint).expect_any_access_token() + } + + /// Creates a prebuilt mock for the MSC3814 endpoint that uploads a fresh + /// dehydrated device. + #[cfg(feature = "e2e-encryption")] + pub fn mock_put_dehydrated_device(&self) -> MockEndpoint<'_, PutDehydratedDeviceEndpoint> { + let mock = Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device$")); + self.mock_endpoint(mock, PutDehydratedDeviceEndpoint).expect_any_access_token() + } + + /// Creates a prebuilt mock for the MSC3814 endpoint that deletes the + /// current dehydrated device. + #[cfg(feature = "e2e-encryption")] + pub fn mock_delete_dehydrated_device( + &self, + ) -> MockEndpoint<'_, DeleteDehydratedDeviceEndpoint> { + let mock = Mock::given(method("DELETE")) + .and(path_regex(r"^/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device$")); + self.mock_endpoint(mock, DeleteDehydratedDeviceEndpoint).expect_any_access_token() + } + + /// Creates a prebuilt mock for the MSC3814 endpoint that fetches the + /// queued to-device events for a dehydrated device. + #[cfg(feature = "e2e-encryption")] + pub fn mock_dehydrated_device_events( + &self, + ) -> MockEndpoint<'_, DehydratedDeviceEventsEndpoint> { + let mock = Mock::given(method("POST")).and(path_regex( + r"^/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/[^/]+/events$", + )); + self.mock_endpoint(mock, DehydratedDeviceEventsEndpoint).expect_any_access_token() + } + /// Creates a prebuilt mock for the endpoint used to leave a room. pub fn mock_room_leave(&self) -> MockEndpoint<'_, RoomLeaveEndpoint> { let mock = @@ -3823,6 +3864,112 @@ impl<'a> MockEndpoint<'a, UploadCrossSigningSignaturesEndpoint> { } } +/// A prebuilt mock for the MSC3814 `GET /dehydrated_device` request. +#[cfg(feature = "e2e-encryption")] +pub struct GetDehydratedDeviceEndpoint; + +#[cfg(feature = "e2e-encryption")] +impl<'a> MockEndpoint<'a, GetDehydratedDeviceEndpoint> { + /// Returns a successful response carrying the given dehydrated device. + pub fn ok(self, device_id: &DeviceId, device_data: Value) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_id": device_id, + "device_data": device_data, + }))) + } + + /// Returns a 404 response with `M_NOT_FOUND`, signalling that no device + /// is currently dehydrated for the user. + pub fn not_found(self) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "errcode": "M_NOT_FOUND", + "error": "No dehydrated device found", + }))) + } +} + +/// A prebuilt mock for the MSC3814 `PUT /dehydrated_device` request. +#[cfg(feature = "e2e-encryption")] +pub struct PutDehydratedDeviceEndpoint; + +#[cfg(feature = "e2e-encryption")] +impl<'a> MockEndpoint<'a, PutDehydratedDeviceEndpoint> { + /// Returns a successful response echoing the supplied device ID. + pub fn ok(self, device_id: &DeviceId) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_id": device_id, + }))) + } + + /// Returns a successful response, computing the response body from the + /// `device_id` field in the request payload. Useful when the caller does + /// not know the device ID ahead of time. + pub fn ok_echo(self) -> MatrixMock<'a> { + self.respond_with(|req: &Request| { + #[derive(serde::Deserialize)] + struct Body { + device_id: OwnedDeviceId, + } + let body: Body = req.body_json().expect("dehydrated device PUT body"); + ResponseTemplate::new(200).set_body_json(json!({ "device_id": body.device_id })) + }) + } +} + +/// A prebuilt mock for the MSC3814 `DELETE /dehydrated_device` request. +#[cfg(feature = "e2e-encryption")] +pub struct DeleteDehydratedDeviceEndpoint; + +#[cfg(feature = "e2e-encryption")] +impl<'a> MockEndpoint<'a, DeleteDehydratedDeviceEndpoint> { + /// Returns a successful response echoing the deleted device ID. + pub fn ok(self, device_id: &DeviceId) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_id": device_id, + }))) + } + + /// Returns a 404 with `M_NOT_FOUND`. + pub fn not_found(self) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "errcode": "M_NOT_FOUND", + "error": "No dehydrated device to delete", + }))) + } +} + +/// A prebuilt mock for the MSC3814 +/// `POST /dehydrated_device/{device_id}/events` request. +#[cfg(feature = "e2e-encryption")] +pub struct DehydratedDeviceEventsEndpoint; + +#[cfg(feature = "e2e-encryption")] +impl<'a> MockEndpoint<'a, DehydratedDeviceEventsEndpoint> { + /// Returns a successful response with the supplied events array and an + /// optional pagination cursor. + pub fn ok(self, events: Vec, next_batch: Option<&str>) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "events": events, + "next_batch": next_batch, + }))) + } + + /// Constrain the mock to only match requests whose `next_batch` body field + /// equals the given token. Pair with [`Self::match_missing_next_batch`] + /// for the initial request in a paginated flow. + pub fn match_next_batch(mut self, token: &str) -> Self { + self.mock = self.mock.and(body_partial_json(json!({ "next_batch": token }))); + self + } + + /// Constrain the mock to only match requests whose body has no + /// `next_batch` field (i.e. the first call in a paginated flow). + pub fn match_missing_next_batch(mut self) -> Self { + self.mock = self.mock.and(body_json(json!({}))); + self + } +} + /// A prebuilt mock for the room leave endpoint. pub struct RoomLeaveEndpoint; From 5a8bfd9588f143a8a4fb7d9e7e6f3fc6a5a38565 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:55:12 +0000 Subject: [PATCH 4/7] test(sdk): integration tests for the MSC3814 dehydrated-devices manager. Drives the full lifecycle through the new `MatrixMockServer` helpers across 13 cases covering support probing, create with default and explicit display names, delete on the three server responses, the rehydrate round trip with single-page and paginated to-device feeds, wrong-pickle-key handling, and the empty-server short-circuit. Signed-off-by: Jason Volk --- .../tests/integration/encryption.rs | 1 + .../encryption/dehydrated_devices.rs | 357 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 crates/matrix-sdk/tests/integration/encryption/dehydrated_devices.rs diff --git a/crates/matrix-sdk/tests/integration/encryption.rs b/crates/matrix-sdk/tests/integration/encryption.rs index aceee7e2e1c..35728e242f1 100644 --- a/crates/matrix-sdk/tests/integration/encryption.rs +++ b/crates/matrix-sdk/tests/integration/encryption.rs @@ -1,5 +1,6 @@ mod backups; mod cross_signing; +mod dehydrated_devices; mod recovery; mod secret_storage; mod shared_history; diff --git a/crates/matrix-sdk/tests/integration/encryption/dehydrated_devices.rs b/crates/matrix-sdk/tests/integration/encryption/dehydrated_devices.rs new file mode 100644 index 00000000000..8b2550b36bb --- /dev/null +++ b/crates/matrix-sdk/tests/integration/encryption/dehydrated_devices.rs @@ -0,0 +1,357 @@ +// Copyright 2026 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::sync::{Arc, Mutex}; + +use assert_matches2::assert_let; +use futures_util::{FutureExt, StreamExt}; +use matrix_sdk::{ + Client, encryption::dehydrated_devices::DehydratedDeviceEvent, + test_utils::mocks::MatrixMockServer, +}; +use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey; +use matrix_sdk_test::async_test; +use ruma::{OwnedDeviceId, owned_device_id, owned_user_id}; +use serde_json::{Value, json}; +use wiremock::{ + Request, + matchers::{body_partial_json, method, path}, +}; + +/// Build a client logged in as Alice with all the standard crypto endpoints +/// preset. +async fn alice_client(server: &MatrixMockServer) -> Client { + server.mock_crypto_endpoints_preset().await; + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("4L1C3"); + server.client_builder_for_crypto_end_to_end(&user_id, &device_id).build().await +} + +/// Bootstrap cross-signing on the client; required before `create` can sign +/// the dehydrated-device payload it uploads. +async fn bootstrap_cross_signing(client: &Client) { + client.encryption().bootstrap_cross_signing(None).await.unwrap(); +} + +/// Captured payload of a `PUT /dehydrated_device` request. +type CapturedDevice = Arc>>; + +/// Mount a `PUT /dehydrated_device` mock that captures the uploaded device +/// data into the returned slot. The slot is later consumed to seed the GET +/// mock during rehydration tests. +async fn capture_uploaded_device(server: &MatrixMockServer) -> CapturedDevice { + let captured: CapturedDevice = Arc::new(Mutex::new(None)); + let sink = captured.clone(); + server + .mock_put_dehydrated_device() + .respond_with(move |req: &Request| { + #[derive(serde::Deserialize)] + struct Body { + device_id: OwnedDeviceId, + device_data: Value, + } + let body: Body = req.body_json().expect("valid PUT body"); + *sink.lock().unwrap() = Some((body.device_id.clone(), body.device_data)); + wiremock::ResponseTemplate::new(200) + .set_body_json(json!({ "device_id": body.device_id })) + }) + .mount() + .await; + captured +} + +#[async_test] +async fn test_is_supported_ok() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server + .mock_get_dehydrated_device() + .ok(&owned_device_id!("DEHYDRATED"), json!({})) + .mock_once() + .mount() + .await; + + assert!(client.encryption().dehydrated_devices().is_supported().await.unwrap()); +} + +#[async_test] +async fn test_is_supported_not_found() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_get_dehydrated_device().not_found().mock_once().mount().await; + + assert!(client.encryption().dehydrated_devices().is_supported().await.unwrap()); +} + +#[async_test] +async fn test_is_supported_unrecognized() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_get_dehydrated_device().error_unrecognized().mock_once().mount().await; + + assert!(!client.encryption().dehydrated_devices().is_supported().await.unwrap()); +} + +#[async_test] +async fn test_is_supported_propagates_other_errors() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_get_dehydrated_device().error500().mock_once().mount().await; + + client + .encryption() + .dehydrated_devices() + .is_supported() + .await + .expect_err("server 500 should propagate"); +} + +#[async_test] +async fn test_create_with_explicit_display_name() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + bootstrap_cross_signing(&client).await; + + server.mock_put_dehydrated_device().ok_echo().mock_once().mount().await; + + let mut events = client.encryption().dehydrated_devices().events(); + let pickle_key = DehydratedDeviceKey::new(); + let device_id = client + .encryption() + .dehydrated_devices() + .create(Some("Bespoke offline catcher"), &pickle_key) + .await + .unwrap(); + + assert_let!( + Some(Ok(DehydratedDeviceEvent::Created { device_id: emitted })) = events.next().await + ); + assert_eq!(emitted, device_id); + assert_let!( + Some(Ok(DehydratedDeviceEvent::Uploaded { device_id: uploaded })) = events.next().await + ); + assert_eq!(uploaded, device_id); +} + +#[async_test] +async fn test_create_uses_default_display_name() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + bootstrap_cross_signing(&client).await; + + // The PUT mock asserts that the upload carries the default display name. + wiremock::Mock::given(method("PUT")) + .and(path("/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device")) + .and(body_partial_json(json!({ + "initial_device_display_name": "Dehydrated device", + }))) + .respond_with(|req: &Request| { + #[derive(serde::Deserialize)] + struct Body { + device_id: OwnedDeviceId, + } + let body: Body = req.body_json().expect("PUT body deserializes"); + wiremock::ResponseTemplate::new(200) + .set_body_json(json!({ "device_id": body.device_id })) + }) + .expect(1) + .mount(server.server()) + .await; + + let pickle_key = DehydratedDeviceKey::new(); + client.encryption().dehydrated_devices().create(None, &pickle_key).await.unwrap(); +} + +#[async_test] +async fn test_delete_emits_event_on_success() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server + .mock_delete_dehydrated_device() + .ok(&owned_device_id!("DEHYDRATED")) + .mock_once() + .mount() + .await; + + let mut events = client.encryption().dehydrated_devices().events(); + client.encryption().dehydrated_devices().delete().await.unwrap(); + + assert_let!(Some(Ok(DehydratedDeviceEvent::Deleted)) = events.next().await); +} + +#[async_test] +async fn test_delete_silent_on_not_found() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_delete_dehydrated_device().not_found().mock_once().mount().await; + + let mut events = client.encryption().dehydrated_devices().events(); + client.encryption().dehydrated_devices().delete().await.unwrap(); + + // No event should have fired; the broadcast channel must remain empty. + assert!(events.next().now_or_never().is_none()); +} + +#[async_test] +async fn test_delete_silent_on_unrecognized() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_delete_dehydrated_device().error_unrecognized().mock_once().mount().await; + + let mut events = client.encryption().dehydrated_devices().events(); + client.encryption().dehydrated_devices().delete().await.unwrap(); + + assert!(events.next().now_or_never().is_none()); +} + +#[async_test] +async fn test_rehydrate_round_trip() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + bootstrap_cross_signing(&client).await; + let pickle_key = DehydratedDeviceKey::new(); + + // First create a real dehydrated device, capturing its payload so the + // subsequent GET can hand the same bytes back. + let captured = capture_uploaded_device(&server).await; + let device_id = + client.encryption().dehydrated_devices().create(None, &pickle_key).await.unwrap(); + + let (uploaded_id, uploaded_data) = + captured.lock().unwrap().clone().expect("PUT mock recorded the upload"); + assert_eq!(uploaded_id, device_id); + + server.mock_get_dehydrated_device().ok(&uploaded_id, uploaded_data).mock_once().mount().await; + server + .mock_dehydrated_device_events() + .match_missing_next_batch() + .ok(vec![], None) + .mock_once() + .mount() + .await; + server.mock_delete_dehydrated_device().ok(&uploaded_id).mock_once().mount().await; + + let mut events = client.encryption().dehydrated_devices().events(); + let outcome = client.encryption().dehydrated_devices().rehydrate(&pickle_key).await.unwrap(); + assert!(outcome); + + assert_let!( + Some(Ok(DehydratedDeviceEvent::RehydrationStarted { device_id: started })) = + events.next().await + ); + assert_eq!(started, device_id); + assert_let!( + Some(Ok(DehydratedDeviceEvent::RehydrationCompleted { device_id: completed, .. })) = + events.next().await + ); + assert_eq!(completed, device_id); + assert_let!(Some(Ok(DehydratedDeviceEvent::Deleted)) = events.next().await); +} + +#[async_test] +async fn test_rehydrate_paginated() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + bootstrap_cross_signing(&client).await; + let pickle_key = DehydratedDeviceKey::new(); + + let captured = capture_uploaded_device(&server).await; + client.encryption().dehydrated_devices().create(None, &pickle_key).await.unwrap(); + let (uploaded_id, uploaded_data) = + captured.lock().unwrap().clone().expect("PUT mock recorded the upload"); + + server.mock_get_dehydrated_device().ok(&uploaded_id, uploaded_data).mock_once().mount().await; + // First call: no next_batch in body, return one event and a cursor. + server + .mock_dehydrated_device_events() + .match_missing_next_batch() + .ok( + vec![json!({ + "type": "m.dummy", + "sender": "@bob:example.org", + "content": {}, + })], + Some("next-cursor"), + ) + .mock_once() + .mount() + .await; + // Second call: body carries the cursor, return an empty batch to terminate. + server + .mock_dehydrated_device_events() + .match_next_batch("next-cursor") + .ok(vec![], None) + .mock_once() + .mount() + .await; + server.mock_delete_dehydrated_device().ok(&uploaded_id).mock_once().mount().await; + + let outcome = client.encryption().dehydrated_devices().rehydrate(&pickle_key).await.unwrap(); + assert!(outcome); +} + +#[async_test] +async fn test_rehydrate_empty_server() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + + server.mock_get_dehydrated_device().not_found().mock_once().mount().await; + + let mut events = client.encryption().dehydrated_devices().events(); + let pickle_key = DehydratedDeviceKey::new(); + let outcome = client.encryption().dehydrated_devices().rehydrate(&pickle_key).await.unwrap(); + assert!(!outcome); + + assert!(events.next().now_or_never().is_none()); +} + +#[async_test] +async fn test_rehydrate_wrong_pickle_key() { + let server = MatrixMockServer::new().await; + let client = alice_client(&server).await; + bootstrap_cross_signing(&client).await; + let correct_key = DehydratedDeviceKey::new(); + let wrong_key = DehydratedDeviceKey::new(); + + let captured = capture_uploaded_device(&server).await; + client.encryption().dehydrated_devices().create(None, &correct_key).await.unwrap(); + let (uploaded_id, uploaded_data) = + captured.lock().unwrap().clone().expect("PUT mock recorded the upload"); + + server.mock_get_dehydrated_device().ok(&uploaded_id, uploaded_data).mock_once().mount().await; + + // Direct callers of rehydrate() get the failure as the Err return value; + // RehydrationError is emitted by start() rather than rehydrate() so a + // direct call here does not produce that event. + client + .encryption() + .dehydrated_devices() + .rehydrate(&wrong_key) + .await + .expect_err("a mismatched pickle key must fail rehydration"); +} + +#[ignore = "SSSS round trip is covered by the live integration test"] +#[async_test] +async fn test_pickle_key_round_trip_through_sssss() { + // The wiremock plumbing for a full SecretStore is out of proportion to + // what this assertion adds on top of the live test; left as a TODO. +} From e04c2bfc117364dc4e8a04c7d5857e293f72c070 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:56:14 +0000 Subject: [PATCH 5/7] test(sdk): live MSC3814 round trip against a real homeserver. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two integration tests under `matrix-sdk-integration-testing` exercising the wire endpoints end to end: a direct `create` → `rehydrate` round trip with a locally generated pickle key, and a `start` lifecycle that resolves the pickle key out of Secret Storage and asserts that two consecutive `start` calls upload distinct device IDs. Signed-off-by: Jason Volk --- .../src/tests/e2ee/dehydrated_devices.rs | 187 ++++++++++++++++++ .../src/tests/e2ee/mod.rs | 1 + 2 files changed, 188 insertions(+) create mode 100644 testing/matrix-sdk-integration-testing/src/tests/e2ee/dehydrated_devices.rs diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/dehydrated_devices.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/dehydrated_devices.rs new file mode 100644 index 00000000000..f008b0ab825 --- /dev/null +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/dehydrated_devices.rs @@ -0,0 +1,187 @@ +// Copyright 2026 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 + +//! Live MSC3814 round trip against a real homeserver. +//! +//! Validates that the [`DehydratedDevices`] manager can: +//! +//! 1. Probe support. +//! 2. Create and upload a dehydrated device with a locally generated pickle +//! key. +//! 3. Rehydrate the very same device and observe that the server-side device +//! is gone afterwards. +//! 4. Drive the same lifecycle from `start` after bootstrapping Recovery, +//! proving that the Secret Storage pickle-key round trip works against a +//! real account-data backend. +//! +//! The test points at `HOMESERVER_URL` and registers a fresh user, so it +//! works against Synapse and Tuwunel without code changes. + +use std::time::Duration; + +use anyhow::Result; +use assert_matches2::assert_let; +use futures::StreamExt; +use matrix_sdk::{ + encryption::{ + EncryptionSettings, + dehydrated_devices::{DehydratedDeviceEvent, StartDehydrationOpts}, + }, + timeout::timeout, +}; +use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey; +use tracing::{info, warn}; + +use crate::helpers::TestClientBuilder; + +/// Direct `create` → `rehydrate` round trip without involving Secret Storage. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_dehydrated_device_direct_round_trip() -> Result<()> { + // Cross-signing must be bootstrapped: a dehydrated device upload signs + // the device keys with the local self-signing key. + let encryption_settings = + EncryptionSettings { auto_enable_cross_signing: true, ..Default::default() }; + + let alice = TestClientBuilder::new("alice_dehydrated_direct") + .use_sqlite() + .encryption_settings(encryption_settings) + .build() + .await?; + alice.encryption().wait_for_e2ee_initialization_tasks().await; + + let dehydrated = alice.encryption().dehydrated_devices(); + if !dehydrated.is_supported().await? { + warn!("Homeserver does not advertise MSC3814; skipping the dehydrated-device round trip"); + return Ok(()); + } + + let mut events = Box::pin(dehydrated.events()); + + let pickle_key = DehydratedDeviceKey::new(); + let device_id = dehydrated.create(Some("Direct round trip"), &pickle_key).await?; + info!(?device_id, "Alice uploaded a dehydrated device"); + + let mut saw_created = false; + let mut saw_uploaded = false; + let mut saw_rh_started = false; + let mut saw_rh_completed = false; + let mut saw_deleted = false; + + // Rehydrate first to emit the matching events; observe both lifecycles + // off the same stream in one drain. + assert!(dehydrated.rehydrate(&pickle_key).await?); + + timeout( + async { + while !(saw_created + && saw_uploaded + && saw_rh_started + && saw_rh_completed + && saw_deleted) + { + let event = events.next().await.expect("event stream is open")?; + match event { + DehydratedDeviceEvent::Created { device_id: id } => { + assert_eq!(id, device_id); + saw_created = true; + } + DehydratedDeviceEvent::Uploaded { device_id: id } => { + assert_eq!(id, device_id); + saw_uploaded = true; + } + DehydratedDeviceEvent::RehydrationStarted { device_id: id } => { + assert_eq!(id, device_id); + saw_rh_started = true; + } + DehydratedDeviceEvent::RehydrationCompleted { device_id: id, .. } => { + assert_eq!(id, device_id); + saw_rh_completed = true; + } + DehydratedDeviceEvent::Deleted => saw_deleted = true, + _ => {} + } + } + Ok::<_, anyhow::Error>(()) + }, + Duration::from_secs(15), + ) + .await??; + + // The server-side device is gone; rehydrate now returns false. + assert_let!(Ok(false) = dehydrated.rehydrate(&pickle_key).await); + + Ok(()) +} + +/// Full `start` lifecycle with Recovery wired up: the pickle key is +/// resolved out of Secret Storage rather than supplied by the caller. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_dehydrated_device_start_via_recovery() -> Result<()> { + let encryption_settings = EncryptionSettings { + auto_enable_cross_signing: true, + auto_enable_backups: true, + ..Default::default() + }; + + let alice = TestClientBuilder::new("alice_dehydrated_start") + .use_sqlite() + .encryption_settings(encryption_settings) + .build() + .await?; + + alice.encryption().wait_for_e2ee_initialization_tasks().await; + + let recovery = alice.encryption().recovery(); + let recovery_key = recovery.enable().await?; + info!("Alice has enabled recovery; the pickle key lives in Secret Storage"); + + let dehydrated = alice.encryption().dehydrated_devices(); + if !dehydrated.is_supported().await? { + warn!("Homeserver does not advertise MSC3814; skipping the start-via-recovery flow"); + return Ok(()); + } + + let secret_store = alice.encryption().secret_storage().open_secret_store(&recovery_key).await?; + + let mut events = Box::pin(dehydrated.events()); + + dehydrated.start(&secret_store, StartDehydrationOpts::default()).await?; + let first_id = timeout( + async { + loop { + let event = events.next().await.expect("event stream is open")?; + if let DehydratedDeviceEvent::Uploaded { device_id } = event { + return Ok::<_, anyhow::Error>(device_id); + } + } + }, + Duration::from_secs(15), + ) + .await??; + info!(?first_id, "Alice's first start uploaded a dehydrated device"); + + dehydrated.start(&secret_store, StartDehydrationOpts::default()).await?; + let second_id = timeout( + async { + loop { + let event = events.next().await.expect("event stream is open")?; + if let DehydratedDeviceEvent::Uploaded { device_id } = event { + return Ok::<_, anyhow::Error>(device_id); + } + } + }, + Duration::from_secs(15), + ) + .await??; + assert_ne!(second_id, first_id, "the rotation must produce a different device id"); + + dehydrated.stop(); + dehydrated.delete().await?; + + Ok(()) +} diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs index d7a0b703502..c7314401861 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs @@ -42,6 +42,7 @@ use tracing::{debug, warn}; use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; +mod dehydrated_devices; mod shared_history; #[cfg(feature = "experimental-encrypted-state-events")] mod state_events; From f12c09034bfffe8180277da4026ad5e53203b0d8 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 09:56:37 +0000 Subject: [PATCH 6/7] feat(ffi): expose the dehydrated-device manager to FFI consumers. Mirrors the SDK shape on the FFI `Encryption` struct with `is_dehydrated_device_supported`, `create_dehydrated_device`, `rehydrate_dehydrated_device`, `delete_dehydrated_device`, `start_dehydrated_devices`, `stop_dehydrated_devices`, and a `dehydrated_device_event_listener` callback interface so Element X (Kotlin and Swift) can drive MSC3814 without bypassing the SDK's lifecycle events. Signed-off-by: Jason Volk --- bindings/matrix-sdk-ffi/src/encryption.rs | 220 +++++++++++++++++++++- 1 file changed, 218 insertions(+), 2 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/encryption.rs b/bindings/matrix-sdk-ffi/src/encryption.rs index 585222db1a0..38a5d1e0b12 100644 --- a/bindings/matrix-sdk-ffi/src/encryption.rs +++ b/bindings/matrix-sdk-ffi/src/encryption.rs @@ -15,8 +15,11 @@ use std::{str::FromStr, sync::Arc}; use futures_util::StreamExt; -use matrix_sdk::encryption::{self, backups, recovery}; -use matrix_sdk_base::crypto::types::{BackupSecrets, RoomKeyBackupInfo}; +use matrix_sdk::encryption::{self, backups, dehydrated_devices as sdk_dd, recovery, vodozemac}; +use matrix_sdk_base::crypto::{ + store::types::DehydratedDeviceKey, + types::{BackupSecrets, RoomKeyBackupInfo}, +}; use matrix_sdk_common::{SendOutsideWasm, SyncOutsideWasm}; use ruma::OwnedUserId; use serde::de::Error; @@ -457,6 +460,133 @@ pub async fn database_contains_secrets_bundle( }) } +/// Lifecycle event emitted by the dehydrated-device manager. +/// +/// Mirrors [`sdk_dd::DehydratedDeviceEvent`]; subscribe via +/// [`Encryption::dehydrated_device_event_listener`]. +#[derive(uniffi::Enum)] +pub enum DehydratedDeviceEvent { + /// A fresh dehydrated device was constructed in the local crypto store, + /// before the upload PUT. + Created { device_id: String }, + /// The homeserver accepted the upload of the dehydrated device. + Uploaded { device_id: String }, + /// The dehydrated device on the homeserver was deleted. + Deleted, + /// A pickle key was cached in the local crypto store. + KeyCached, + /// Rehydration of a dehydrated device began. + RehydrationStarted { device_id: String }, + /// A batch of to-device events has been imported during rehydration. + RehydrationProgress { room_keys_imported: u64, to_device_events: u64 }, + /// Rehydration finished successfully. + RehydrationCompleted { device_id: String, room_keys_imported: u64, to_device_events: u64 }, + /// Rehydration failed. + RehydrationError { error: String }, + /// A scheduled rotation tick failed; the rotation task remains scheduled. + RotationError { error: String }, +} + +impl From for DehydratedDeviceEvent { + fn from(value: sdk_dd::DehydratedDeviceEvent) -> Self { + match value { + sdk_dd::DehydratedDeviceEvent::Created { device_id } => { + Self::Created { device_id: device_id.to_string() } + } + sdk_dd::DehydratedDeviceEvent::Uploaded { device_id } => { + Self::Uploaded { device_id: device_id.to_string() } + } + sdk_dd::DehydratedDeviceEvent::Deleted => Self::Deleted, + sdk_dd::DehydratedDeviceEvent::KeyCached => Self::KeyCached, + sdk_dd::DehydratedDeviceEvent::RehydrationStarted { device_id } => { + Self::RehydrationStarted { device_id: device_id.to_string() } + } + sdk_dd::DehydratedDeviceEvent::RehydrationProgress { + room_keys_imported, + to_device_events, + } => Self::RehydrationProgress { + room_keys_imported: room_keys_imported as u64, + to_device_events: to_device_events as u64, + }, + sdk_dd::DehydratedDeviceEvent::RehydrationCompleted { + device_id, + room_keys_imported, + to_device_events, + } => Self::RehydrationCompleted { + device_id: device_id.to_string(), + room_keys_imported: room_keys_imported as u64, + to_device_events: to_device_events as u64, + }, + sdk_dd::DehydratedDeviceEvent::RehydrationError { error } => { + Self::RehydrationError { error } + } + sdk_dd::DehydratedDeviceEvent::RotationError { error } => Self::RotationError { error }, + } + } +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait DehydratedDeviceEventListener: SyncOutsideWasm + SendOutsideWasm { + fn on_event(&self, event: DehydratedDeviceEvent); +} + +/// Options for [`Encryption::start_dehydrated_devices`]. +#[derive(uniffi::Record)] +pub struct StartDehydratedDevicesOpts { + /// Force generation of a fresh random pickle key on start, replacing + /// any existing entry in Secret Storage and the local cache. + pub create_new_key: bool, + /// Whether to attempt to rehydrate the existing dehydrated device, if + /// any, before creating the next one. + pub rehydrate: bool, + /// If `true`, the call becomes a no-op when no pickle key is cached + /// locally. + pub only_if_key_cached: bool, +} + +impl From for sdk_dd::StartDehydrationOpts { + fn from(value: StartDehydratedDevicesOpts) -> Self { + Self { + create_new_key: value.create_new_key, + rehydrate: value.rehydrate, + only_if_key_cached: value.only_if_key_cached, + } + } +} + +/// Errors returned by the dehydrated-device FFI surface. +#[derive(Debug, Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum DehydratedDeviceError { + /// The client is not logged in. + #[error("the client is not logged in")] + NotLoggedIn, + /// The supplied base64-encoded pickle key did not decode to 32 bytes. + #[error("the dehydrated-device pickle key must decode to 32 bytes of base64")] + InvalidPickleKey, + /// Opening Secret Storage with the supplied recovery key failed. + #[error("could not open Secret Storage: {0}")] + SecretStorage(String), + /// Any other failure surfaced by the SDK. + #[error("{0}")] + Sdk(String), +} + +impl From for DehydratedDeviceError { + fn from(value: sdk_dd::DehydratedDeviceError) -> Self { + match value { + sdk_dd::DehydratedDeviceError::NotLoggedIn => Self::NotLoggedIn, + other => Self::Sdk(other.to_string()), + } + } +} + +fn decode_pickle_key(base64: &str) -> Result { + let bytes = + vodozemac::base64_decode(base64).map_err(|_| DehydratedDeviceError::InvalidPickleKey)?; + DehydratedDeviceKey::from_slice(&bytes).map_err(|_| DehydratedDeviceError::InvalidPickleKey) +} + #[matrix_sdk_ffi_macros::export] impl Encryption { /// Get the public ed25519 key of our own device. This is usually what is @@ -761,6 +891,92 @@ impl Encryption { }) } } + + /// Return whether the homeserver advertises support for MSC3814 + /// dehydrated devices. + pub async fn is_dehydrated_device_supported(&self) -> Result { + Ok(self.inner.dehydrated_devices().is_supported().await?) + } + + /// Build a fresh dehydrated device, encrypt it with the supplied pickle + /// key, and upload it to the homeserver. Returns the new device ID. + /// + /// The pickle key is a 32-byte secret, base64 encoded. Callers are + /// responsible for storing the pickle key safely (typically in Secret + /// Storage via [`Encryption::start_dehydrated_devices`]). + pub async fn create_dehydrated_device( + &self, + display_name: Option, + pickle_key: String, + ) -> Result { + let key = decode_pickle_key(&pickle_key)?; + let id = self.inner.dehydrated_devices().create(display_name.as_deref(), &key).await?; + Ok(id.to_string()) + } + + /// Rehydrate the dehydrated device currently on the server, if any. + /// + /// Returns `true` if a device was rehydrated end to end, `false` if the + /// server reports no dehydrated device or does not implement the endpoint. + pub async fn rehydrate_dehydrated_device( + &self, + pickle_key: String, + ) -> Result { + let key = decode_pickle_key(&pickle_key)?; + Ok(self.inner.dehydrated_devices().rehydrate(&key).await?) + } + + /// Delete the current dehydrated device, if one exists. Silent if no + /// device is on the server or the server does not implement MSC3814. + pub async fn delete_dehydrated_device(&self) -> Result<(), DehydratedDeviceError> { + Ok(self.inner.dehydrated_devices().delete().await?) + } + + /// Start using dehydrated devices for this client, resolving the pickle + /// key through Secret Storage and scheduling weekly rotation. + /// + /// The recovery key is consumed (zeroized) after Secret Storage has been + /// unlocked. + pub async fn start_dehydrated_devices( + &self, + mut recovery_key: String, + opts: StartDehydratedDevicesOpts, + ) -> Result<(), DehydratedDeviceError> { + let secret_store = self + .inner + .secret_storage() + .open_secret_store(&recovery_key) + .await + .map_err(|e| DehydratedDeviceError::SecretStorage(e.to_string()))?; + recovery_key.zeroize(); + self.inner.dehydrated_devices().start(&secret_store, opts.into()).await?; + Ok(()) + } + + /// Stop the scheduled dehydrated-device rotation. + /// + /// Has no effect when no rotation is scheduled. Existing dehydrated + /// devices on the server are left in place; pair with + /// [`Encryption::delete_dehydrated_device`] to remove them. + pub fn stop_dehydrated_devices(&self) { + self.inner.dehydrated_devices().stop(); + } + + /// Subscribe to lifecycle events emitted by the dehydrated-device + /// manager. The returned [`TaskHandle`] keeps the listener alive; drop + /// it to unsubscribe. + pub fn dehydrated_device_event_listener( + &self, + listener: Box, + ) -> Arc { + let mut events = Box::pin(self.inner.dehydrated_devices().events()); + Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + while let Some(event) = events.next().await { + let Ok(event) = event else { continue }; + listener.on_event(event.into()); + } + }))) + } } /// The E2EE identity of a user. From 0bbe969a2e061a1e59d15fd1e6d21d9001a9e478 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 May 2026 10:26:29 +0000 Subject: [PATCH 7/7] chore(changelog): add MSC3814 dehydrated-devices fragments. Signed-off-by: Jason Volk --- bindings/matrix-sdk-ffi/changelog.d/6606.added.md | 7 +++++++ crates/matrix-sdk/changelog.d/6606.added.md | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 bindings/matrix-sdk-ffi/changelog.d/6606.added.md create mode 100644 crates/matrix-sdk/changelog.d/6606.added.md diff --git a/bindings/matrix-sdk-ffi/changelog.d/6606.added.md b/bindings/matrix-sdk-ffi/changelog.d/6606.added.md new file mode 100644 index 00000000000..e5e42514a34 --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6606.added.md @@ -0,0 +1,7 @@ +Expose the [MSC3814] dehydrated-device manager on `Encryption`: +`is_dehydrated_device_supported`, `create_dehydrated_device`, +`rehydrate_dehydrated_device`, `delete_dehydrated_device`, +`start_dehydrated_devices`, `stop_dehydrated_devices`, and a +`dehydrated_device_event_listener` callback for lifecycle observability. + +[MSC3814]: https://github.com/matrix-org/matrix-spec-proposals/pull/3814 diff --git a/crates/matrix-sdk/changelog.d/6606.added.md b/crates/matrix-sdk/changelog.d/6606.added.md new file mode 100644 index 00000000000..e628f06194a --- /dev/null +++ b/crates/matrix-sdk/changelog.d/6606.added.md @@ -0,0 +1,8 @@ +Add `Encryption::dehydrated_devices()`, a high-level [MSC3814] dehydrated-device +manager that wraps the crypto-crate primitives (probe support, create, +rehydrate, delete, weekly rotation, Secret Storage round trip for the pickle +key, lifecycle event stream) and is modelled on `matrix-js-sdk`'s +`DehydratedDeviceManager`. `MatrixMockServer` gains `mock_*` helpers for the +four MSC3814 endpoints. + +[MSC3814]: https://github.com/matrix-org/matrix-spec-proposals/pull/3814