Skip to main content

matrix_sdk/encryption/
dehydrated_devices.rs

1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! High-level interface for [Dehydrated Devices] ([MSC3814]).
16//!
17//! A dehydrated device is a virtual device the homeserver keeps on the user's
18//! behalf while no live device is online. Senders can encrypt to it using the
19//! same Olm session establishment as for any other device. When a new device
20//! comes online it rehydrates: it pulls the private parts of the virtual
21//! device back down, decrypts them with a pickle key, drains the queued
22//! to-device events, and imports the room keys they carry.
23//!
24//! # Lifecycle
25//!
26//! 1. `is_supported`: cheap probe of the homeserver.
27//! 2. `create`: build a fresh dehydrated device and upload it. The pickle key
28//!    is supplied by the caller; storage and rotation of the pickle key are an
29//!    application concern.
30//! 3. `rehydrate`: pull the existing dehydrated device, decrypt with the pickle
31//!    key, absorb queued to-device events, and delete the device.
32//! 4. `delete`: remove the current dehydrated device without rehydrating.
33//!
34//! # Example
35//!
36//! ```no_run
37//! # use matrix_sdk::Client;
38//! # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey;
39//! # async fn example(client: Client, pickle_key: DehydratedDeviceKey)
40//! # -> anyhow::Result<()> {
41//! let dehydrated = client.encryption().dehydrated_devices();
42//!
43//! if !dehydrated.is_supported().await? {
44//!     return Ok(());
45//! }
46//!
47//! // The pickle key comes from Secret Storage: only the key that encrypted
48//! // the existing device can rehydrate it. `start` manages that round trip.
49//! dehydrated.rehydrate(&pickle_key).await?;
50//! dehydrated.create(None, &pickle_key).await?;
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! [Dehydrated Devices]: https://spec.matrix.org/unstable/client-server-api/#dehydrated-devices
56//! [MSC3814]: https://github.com/matrix-org/matrix-spec-proposals/pull/3814
57
58use std::{future::IntoFuture, time::Duration};
59
60use futures_core::Stream;
61use matrix_sdk_base::crypto::{
62    OlmError,
63    dehydrated_devices::{DehydrationError, RehydratedDevice},
64    store::types::DehydratedDeviceKey,
65    vodozemac::base64_decode,
66};
67use matrix_sdk_common::{
68    boxed_into_future, locks::Mutex as StdMutex, sleep::sleep, task_monitor::BackgroundTaskHandle,
69};
70use ruma::{
71    OwnedDeviceId,
72    api::{
73        client::dehydrated_device::{
74            DehydratedDeviceData, delete_dehydrated_device, get_dehydrated_device, get_events,
75        },
76        error::ErrorKind,
77    },
78    events::secret::request::SecretName,
79    serde::Raw,
80};
81use thiserror::Error;
82use tokio::sync::broadcast;
83use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
84use tracing::{Instrument, Span, debug, info, instrument, trace, warn};
85use zeroize::Zeroizing;
86
87use crate::{
88    Client, HttpError,
89    client::WeakClient,
90    encryption::{CryptoStoreError, secret_storage::SecretStore},
91};
92
93/// The default display name uploaded for a freshly created dehydrated device.
94const DEFAULT_DEVICE_DISPLAY_NAME: &str = "Dehydrated device";
95
96/// The name used to store the dehydrated-device pickle key in Secret Storage.
97///
98/// MSC3814 reserves `m.dehydrated_device` for the stable name; this is the
99/// unstable equivalent the implementation will publish until the MSC
100/// stabilizes.
101const PICKLE_KEY_SECRET_NAME: &str = "org.matrix.msc3814";
102
103/// How often the rotation task started by [`DehydratedDevices::start`]
104/// re-creates the dehydrated device. Fixed at one week.
105const DEHYDRATION_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
106
107/// Crypto-store key under which the ID of the most recently uploaded dehydrated
108/// device is persisted, so the replay check in [`DehydratedDevices::rehydrate`]
109/// survives a client restart.
110const LAST_UPLOADED_DEVICE_ID_KEY: &str = "matrix-sdk-dehydrated-devices.last-uploaded-device-id";
111
112/// Defensive ceiling on the number of to-device events drained from a single
113/// dehydrated device during rehydration. MSC3814 does not specify a limit; this
114/// bounds the drain loop so a server that keeps returning events cannot make it
115/// run forever, and keeps the running count well clear of overflow.
116const MAX_TO_DEVICE_EVENTS: usize = 100_000;
117
118/// Errors that can occur while managing dehydrated devices.
119#[derive(Debug, Error)]
120pub enum DehydratedDeviceError {
121    /// The HTTP request to the homeserver failed.
122    #[error(transparent)]
123    Http(#[from] HttpError),
124
125    /// The cryptographic operation on the dehydrated device failed.
126    #[error(transparent)]
127    Crypto(#[from] DehydrationError),
128
129    /// Importing room keys from a rehydrated device's to-device events
130    /// failed.
131    #[error(transparent)]
132    Olm(#[from] OlmError),
133
134    /// The to-device drain during rehydration stopped before the server's
135    /// queue was exhausted; the dehydrated device was left in place so a
136    /// retry can resume the drain.
137    #[error(
138        "the to-device drain stopped after {to_device_events} events with more still queued; the dehydrated device was kept so a retry can resume"
139    )]
140    DrainTruncated {
141        /// Number of to-device events processed before the drain stopped.
142        to_device_events: usize,
143    },
144
145    /// The crypto store could not be accessed.
146    #[error(transparent)]
147    Store(#[from] CryptoStoreError),
148
149    /// Reading or writing a secret to Secret Storage failed.
150    #[error(transparent)]
151    SecretStorage(#[from] crate::encryption::secret_storage::SecretStorageError),
152
153    /// The pickle key stored in Secret Storage was not valid base64.
154    #[error("the dehydrated-device pickle key in Secret Storage is not valid base64: {0}")]
155    PickleKeyDecode(#[from] vodozemac::Base64DecodeError),
156
157    /// The client is not logged in; the Olm machine is not available.
158    #[error("the client is not logged in")]
159    NotLoggedIn,
160}
161
162/// Return a [`SecretName`] for the dehydrated-device pickle key entry.
163fn pickle_key_secret_name() -> SecretName {
164    SecretName::from(PICKLE_KEY_SECRET_NAME)
165}
166
167/// Lifecycle events emitted by [`DehydratedDevices`].
168///
169/// Subscribe with [`DehydratedDevices::state_stream`] to observe creation,
170/// rehydration progress, and rotation outcomes. [`Self::RehydrationCompleted`]
171/// carries the final imported counts so a caller does not have to fold over the
172/// [`Self::RehydrationProgress`] events, and [`Self::RotationError`] surfaces
173/// background rotation failures the task would otherwise swallow.
174#[derive(Clone, Debug)]
175pub enum DehydratedDeviceEvent {
176    /// A fresh dehydrated device was constructed in the local crypto
177    /// store, before the upload PUT.
178    Created {
179        /// Device ID assigned to the new dehydrated device.
180        device_id: OwnedDeviceId,
181    },
182    /// The dehydrated device announced by the preceding
183    /// [`Self::Created`] event was accepted by the homeserver.
184    Uploaded {
185        /// Device ID of the dehydrated device now visible on the server.
186        device_id: OwnedDeviceId,
187    },
188    /// The dehydrated device currently on the server was deleted.
189    Deleted,
190    /// A pickle key was cached in the local crypto store.
191    KeyCached,
192    /// Rehydration of a dehydrated device began.
193    RehydrationStarted {
194        /// Device ID of the dehydrated device being rehydrated.
195        device_id: OwnedDeviceId,
196    },
197    /// A batch of to-device events has been imported during rehydration.
198    RehydrationProgress {
199        /// Cumulative number of room keys imported so far.
200        room_keys_imported: usize,
201        /// Cumulative number of to-device events processed so far.
202        to_device_events: usize,
203    },
204    /// Rehydration finished successfully.
205    RehydrationCompleted {
206        /// Device ID of the rehydrated device.
207        device_id: OwnedDeviceId,
208        /// Total number of room keys imported.
209        room_keys_imported: usize,
210        /// Total number of to-device events processed.
211        to_device_events: usize,
212    },
213    /// Rehydration failed before it could complete.
214    RehydrationError {
215        /// Human-readable description of the failure.
216        error: String,
217    },
218    /// A scheduled rotation tick failed; the rotation task remains
219    /// scheduled and will retry at the next tick.
220    RotationError {
221        /// Human-readable description of the failure.
222        error: String,
223    },
224}
225
226/// Process-wide state for the dehydrated-devices manager.
227///
228/// Held inside [`crate::encryption::EncryptionData`] so the event sender
229/// and any in-flight rotation task survive across
230/// `Client::encryption().dehydrated_devices()` calls.
231pub(crate) struct DehydratedDevicesState {
232    event_sender: broadcast::Sender<DehydratedDeviceEvent>,
233    rotation_task: StdMutex<Option<BackgroundTaskHandle>>,
234}
235
236impl Default for DehydratedDevicesState {
237    fn default() -> Self {
238        let (event_sender, _) = broadcast::channel(100);
239        Self { event_sender, rotation_task: StdMutex::new(None) }
240    }
241}
242
243/// High-level handle returned by
244/// [`Encryption::dehydrated_devices`](crate::encryption::Encryption::dehydrated_devices).
245#[derive(Debug, Clone)]
246pub struct DehydratedDevices {
247    pub(super) client: Client,
248}
249
250/// The dehydrated device the server currently holds on the user's behalf.
251struct DownloadedDevice {
252    device_id: OwnedDeviceId,
253    device_data: Raw<DehydratedDeviceData>,
254}
255
256/// Outcome of draining the dehydrated device's to-device queue.
257struct DrainOutcome {
258    room_keys_imported: usize,
259    to_device_events: usize,
260
261    /// Whether the drain stopped defensively (batch cap or a repeated
262    /// cursor) with events potentially still queued on the server.
263    truncated: bool,
264}
265
266impl DehydratedDevices {
267    /// Subscribe to the stream of [`DehydratedDeviceEvent`]s.
268    ///
269    /// Each call returns a fresh stream. If a subscriber is slow enough to
270    /// fall behind the channel's buffer, it receives a
271    /// [`BroadcastStreamRecvError`] reporting the number of skipped events
272    /// and the stream continues from the most recent event.
273    ///
274    /// # Example
275    ///
276    /// ```no_run
277    /// # use matrix_sdk::Client;
278    /// # use futures_util::StreamExt;
279    /// # async fn example(client: Client) -> anyhow::Result<()> {
280    /// let dehydrated = client.encryption().dehydrated_devices();
281    /// let mut stream = dehydrated.state_stream();
282    /// while let Some(Ok(event)) = stream.next().await {
283    ///     println!("dehydrated devices: {event:?}");
284    /// }
285    /// # Ok(()) }
286    /// ```
287    pub fn state_stream(
288        &self,
289    ) -> impl Stream<Item = Result<DehydratedDeviceEvent, BroadcastStreamRecvError>> + use<> {
290        BroadcastStream::new(self.state().event_sender.subscribe())
291    }
292
293    fn state(&self) -> &DehydratedDevicesState {
294        &self.client.inner.e2ee.dehydrated_devices_state
295    }
296
297    fn emit(&self, event: DehydratedDeviceEvent) {
298        // A send failure means there are no subscribers; that is fine.
299        let _ = self.state().event_sender.send(event);
300    }
301
302    /// Return whether the homeserver advertises dehydrated-device support.
303    ///
304    /// Probes by issuing `GET /dehydrated_device` and inspecting the errcode
305    /// of the response:
306    ///
307    /// - `M_UNRECOGNIZED` means the server does not understand the endpoint.
308    /// - `M_NOT_FOUND` or a successful response means the server understands
309    ///   the endpoint (whether or not the user currently has a dehydrated
310    ///   device on file).
311    ///
312    /// Any other transport or API failure is propagated.
313    #[instrument(skip_all)]
314    pub async fn is_supported(&self) -> Result<bool, DehydratedDeviceError> {
315        let request = get_dehydrated_device::unstable::Request::new();
316        match self.client.send(request).await {
317            Ok(_) => Ok(true),
318            Err(e) => match e.client_api_error_kind() {
319                Some(ErrorKind::Unrecognized) => Ok(false),
320                Some(ErrorKind::NotFound) => Ok(true),
321                _ => Err(e.into()),
322            },
323        }
324    }
325
326    /// Create a fresh dehydrated device and upload it to the homeserver.
327    ///
328    /// The pickle key is used by [vodozemac] to encrypt the private parts of
329    /// the device. The application is responsible for safely storing the
330    /// pickle key (typically in Secret Storage so future sessions can
331    /// rehydrate the device).
332    ///
333    /// # Arguments
334    ///
335    /// * `display_name` - Optional human-readable name uploaded as the
336    ///   dehydrated device's `initial_device_display_name`. Defaults to
337    ///   `"Dehydrated device"`.
338    /// * `pickle_key` - 32-byte key used to encrypt the dehydrated device.
339    ///
340    /// # Example
341    ///
342    /// ```no_run
343    /// # use matrix_sdk::Client;
344    /// # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey;
345    /// # async fn example(client: Client) -> anyhow::Result<()> {
346    /// let pickle_key = DehydratedDeviceKey::new();
347    /// let device_id = client
348    ///     .encryption()
349    ///     .dehydrated_devices()
350    ///     .create(Some("Offline catcher"), &pickle_key)
351    ///     .await?;
352    /// println!("Uploaded dehydrated device {device_id}");
353    /// # Ok(()) }
354    /// ```
355    ///
356    /// [vodozemac]: https://docs.rs/vodozemac/
357    #[instrument(skip_all)]
358    pub async fn create(
359        &self,
360        display_name: Option<&str>,
361        pickle_key: &DehydratedDeviceKey,
362    ) -> Result<OwnedDeviceId, DehydratedDeviceError> {
363        let olm = self.client.olm_machine().await;
364        let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
365
366        debug!("Creating a new dehydrated device in the crypto store");
367        let dehydrated_device = machine.dehydrated_devices().create().await?;
368
369        let display_name = display_name.unwrap_or(DEFAULT_DEVICE_DISPLAY_NAME);
370        let request =
371            dehydrated_device.keys_for_upload(display_name.to_owned(), pickle_key).await?;
372        let device_id = request.device_id.clone();
373        self.emit(DehydratedDeviceEvent::Created { device_id: device_id.clone() });
374
375        debug!(?device_id, "Uploading dehydrated device to the homeserver");
376        self.client.send(request).await?;
377        info!(?device_id, "Successfully uploaded dehydrated device");
378
379        machine.store().set_value(LAST_UPLOADED_DEVICE_ID_KEY, &device_id).await?;
380        self.emit(DehydratedDeviceEvent::Uploaded { device_id: device_id.clone() });
381        Ok(device_id)
382    }
383
384    /// Rehydrate the dehydrated device currently on the server, if any.
385    ///
386    /// Downloads the dehydrated device, decrypts it with `pickle_key`,
387    /// drains all queued to-device events to import their room keys, and
388    /// finally deletes the device from the server.
389    ///
390    /// Returns `Ok(false)` if the server reports no dehydrated device
391    /// (`M_NOT_FOUND`) or does not implement the endpoint
392    /// (`M_UNRECOGNIZED`). Returns `Ok(true)` once the rehydration cycle
393    /// has completed end to end.
394    ///
395    /// If the drain stops defensively before the server's queue is
396    /// exhausted, the device is left on the server and
397    /// [`DehydratedDeviceError::DrainTruncated`] is returned. Calling this
398    /// method again restarts the drain from the beginning of the queue;
399    /// room keys imported by the earlier attempt import idempotently.
400    ///
401    /// # Example
402    ///
403    /// ```no_run
404    /// # use matrix_sdk::Client;
405    /// # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey;
406    /// # async fn example(client: Client, pickle_key: DehydratedDeviceKey)
407    /// # -> anyhow::Result<()> {
408    /// let rehydrated =
409    ///     client.encryption().dehydrated_devices().rehydrate(&pickle_key).await?;
410    /// if rehydrated {
411    ///     println!("Caught up on offline room keys");
412    /// }
413    /// # Ok(()) }
414    /// ```
415    #[instrument(skip_all)]
416    pub async fn rehydrate(
417        &self,
418        pickle_key: &DehydratedDeviceKey,
419    ) -> Result<bool, DehydratedDeviceError> {
420        let Some(downloaded) = self.download_device().await? else { return Ok(false) };
421        info!(device_id = ?downloaded.device_id, "Dehydrated device found");
422
423        // The server should serve back whichever id we last uploaded. A
424        // mismatch can indicate a stale or replayed payload; warn so the
425        // application can decide to drop or quarantine the imported keys. The
426        // last-uploaded id is persisted in the crypto store, so this check
427        // holds across restarts.
428        if let Some(expected) = self.last_uploaded_device_id().await?
429            && expected != downloaded.device_id
430        {
431            warn!(
432                ?expected,
433                got = ?downloaded.device_id,
434                "Server returned a different dehydrated-device id than the one we last uploaded; continuing but the payload may be stale"
435            );
436        }
437
438        self.emit(DehydratedDeviceEvent::RehydrationStarted {
439            device_id: downloaded.device_id.clone(),
440        });
441
442        let rehydrated = self.rehydrate_device(&downloaded, pickle_key).await?;
443        let drained = self.absorb_events(&downloaded.device_id, &rehydrated).await?;
444
445        // The server keeps undelivered events queued until the device is
446        // deleted, so a truncated drain must leave the device in place for a
447        // retry to resume; deleting now would discard the rest of the queue.
448        if drained.truncated {
449            return Err(DehydratedDeviceError::DrainTruncated {
450                to_device_events: drained.to_device_events,
451            });
452        }
453
454        self.emit(DehydratedDeviceEvent::RehydrationCompleted {
455            device_id: downloaded.device_id.clone(),
456            room_keys_imported: drained.room_keys_imported,
457            to_device_events: drained.to_device_events,
458        });
459
460        // Key import already succeeded; if the post-drain delete fails, log
461        // it but do not let the failure masquerade as a rehydration error.
462        // The next create() call will replace the device anyway.
463        if let Err(e) = self.delete_device().await {
464            warn!(device_id = ?downloaded.device_id, error = %e, "Post-rehydration delete failed; the next rotation will replace the device");
465        }
466
467        Ok(true)
468    }
469
470    /// Cache the pickle key in the local crypto store.
471    ///
472    /// Subsequent rehydration attempts can then resolve the key from the
473    /// cache without an account-data round-trip.
474    #[instrument(skip_all)]
475    pub(crate) async fn cache_key(
476        &self,
477        pickle_key: &DehydratedDeviceKey,
478    ) -> Result<(), DehydratedDeviceError> {
479        let olm = self.client.olm_machine().await;
480        let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
481
482        machine.dehydrated_devices().save_dehydrated_device_pickle_key(pickle_key).await?;
483        self.emit(DehydratedDeviceEvent::KeyCached);
484        Ok(())
485    }
486
487    /// Return the pickle key currently cached in the local crypto store.
488    ///
489    /// `Ok(None)` if no key has been cached. The returned key matches the
490    /// last value persisted via [`cache_key`](Self::cache_key) or
491    /// [`reset_key`](Self::reset_key); it is not fetched from Secret Storage.
492    #[instrument(skip_all)]
493    pub(crate) async fn cached_key(
494        &self,
495    ) -> Result<Option<DehydratedDeviceKey>, DehydratedDeviceError> {
496        let olm = self.client.olm_machine().await;
497        let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
498
499        Ok(machine.dehydrated_devices().get_dehydrated_device_pickle_key().await?)
500    }
501
502    /// Return the ID of the dehydrated device this client most recently
503    /// uploaded, as persisted in the crypto store, or `Ok(None)` if none has
504    /// been uploaded yet.
505    async fn last_uploaded_device_id(
506        &self,
507    ) -> Result<Option<OwnedDeviceId>, DehydratedDeviceError> {
508        let olm = self.client.olm_machine().await;
509        let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
510
511        Ok(machine.store().get_value(LAST_UPLOADED_DEVICE_ID_KEY).await?)
512    }
513
514    /// Return whether the pickle key is stored in the given Secret Storage.
515    ///
516    /// The key is looked up by the account-data event type
517    /// `org.matrix.msc3814` (the unstable name reserved by MSC3814).
518    ///
519    /// # Example
520    ///
521    /// ```no_run
522    /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore};
523    /// # async fn example(client: Client, store: SecretStore)
524    /// # -> anyhow::Result<()> {
525    /// let stored =
526    ///     client.encryption().dehydrated_devices().is_key_stored(&store).await?;
527    /// # Ok(()) }
528    /// ```
529    #[instrument(skip_all)]
530    pub async fn is_key_stored(
531        &self,
532        secret_store: &SecretStore,
533    ) -> Result<bool, DehydratedDeviceError> {
534        Ok(secret_store.get_secret(pickle_key_secret_name()).await?.is_some())
535    }
536
537    /// Generate a new random pickle key, persist it in Secret Storage, and
538    /// cache it in the local crypto store.
539    ///
540    /// The previous key (if any) is overwritten in both places. Any
541    /// dehydrated device that was encrypted with the previous key becomes
542    /// unrehydratable until rotated.
543    ///
544    /// # Example
545    ///
546    /// ```no_run
547    /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore};
548    /// # async fn example(client: Client, store: SecretStore)
549    /// # -> anyhow::Result<()> {
550    /// let fresh_key =
551    ///     client.encryption().dehydrated_devices().reset_key(&store).await?;
552    /// # let _ = fresh_key;
553    /// # Ok(()) }
554    /// ```
555    #[instrument(skip_all)]
556    pub async fn reset_key(
557        &self,
558        secret_store: &SecretStore,
559    ) -> Result<DehydratedDeviceKey, DehydratedDeviceError> {
560        let key = DehydratedDeviceKey::new();
561        secret_store.put_secret(pickle_key_secret_name(), &key.to_base64()).await?;
562        self.cache_key(&key).await?;
563        Ok(key)
564    }
565
566    /// Resolve the pickle key.
567    ///
568    /// Looks for the key in this order:
569    ///
570    /// 1. The local crypto-store cache.
571    /// 2. The provided Secret Storage account-data entry. A successfully
572    ///    fetched key is written back to the cache.
573    /// 3. If `create_if_missing`, a fresh random key is generated, stored, and
574    ///    cached.
575    ///
576    /// Returns `Ok(None)` only when the key is absent from both sources and
577    /// `create_if_missing` is `false`.
578    #[instrument(skip_all)]
579    pub(crate) async fn load_key(
580        &self,
581        secret_store: &SecretStore,
582        create_if_missing: bool,
583    ) -> Result<Option<DehydratedDeviceKey>, DehydratedDeviceError> {
584        if let Some(cached) = self.cached_key().await? {
585            return Ok(Some(cached));
586        }
587
588        let Some(base64) = secret_store.get_secret(pickle_key_secret_name()).await? else {
589            return if create_if_missing {
590                Ok(Some(self.reset_key(secret_store).await?))
591            } else {
592                Ok(None)
593            };
594        };
595
596        let bytes = Zeroizing::new(base64_decode(&base64)?);
597        let key = DehydratedDeviceKey::from_slice(&bytes)?;
598        self.cache_key(&key).await?;
599        Ok(Some(key))
600    }
601
602    /// Start using dehydrated devices for this client.
603    ///
604    /// Returns a [`StartDehydration`] future. Await it directly for the default
605    /// behavior, or configure it first with
606    /// [`StartDehydration::create_new_key`],
607    /// [`StartDehydration::skip_rehydration`], and
608    /// [`StartDehydration::only_if_key_cached`].
609    ///
610    /// The caller is expected to have unlocked Secret Storage and bootstrapped
611    /// cross-signing before this call; otherwise the underlying account-data
612    /// reads and writes will fail mid-flight.
613    ///
614    /// Awaiting the future performs the following steps:
615    ///
616    /// 1. If [`StartDehydration::only_if_key_cached`] was set, return early
617    ///    when no pickle key is cached locally.
618    /// 2. Stop any previously scheduled rotation.
619    /// 3. Unless [`StartDehydration::skip_rehydration`] was set, attempt to
620    ///    rehydrate the existing dehydrated device. Failures are logged and
621    ///    emitted as [`DehydratedDeviceEvent::RehydrationError`] but do not
622    ///    abort the start.
623    /// 4. If [`StartDehydration::create_new_key`] was set and the rehydration
624    ///    step succeeded (or was skipped), replace the pickle key in Secret
625    ///    Storage with a fresh random one. A failed rehydration suppresses the
626    ///    reset so the stored key can still recover the existing dehydrated
627    ///    device on another client.
628    /// 5. Create a new dehydrated device now and schedule rotation once a week.
629    ///
630    /// The rotation task resolves the pickle key from the local crypto store on
631    /// each tick. The local cache is the only key source available to the task
632    /// because reopening Secret Storage requires the recovery key, which is not
633    /// retained. Per-tick failures emit
634    /// [`DehydratedDeviceEvent::RotationError`] without aborting the schedule.
635    ///
636    /// # Example
637    ///
638    /// ```no_run
639    /// # use matrix_sdk::{Client, encryption::secret_storage::SecretStore};
640    /// # async fn example(client: Client, store: SecretStore)
641    /// # -> anyhow::Result<()> {
642    /// client.encryption().dehydrated_devices().start(&store).await?;
643    /// # Ok(()) }
644    /// ```
645    pub fn start<'a>(&'a self, secret_store: &'a SecretStore) -> StartDehydration<'a> {
646        StartDehydration::new(self, secret_store)
647    }
648
649    /// Stop the scheduled dehydrated-device rotation, if any.
650    ///
651    /// Has no effect when no rotation is scheduled. Existing dehydrated
652    /// devices on the server are left in place; pair with
653    /// [`Self::delete`] to clean those up.
654    pub fn stop(&self) {
655        self.state().rotation_task.lock().take();
656    }
657
658    /// Create-and-upload the first dehydrated device now, then spawn the
659    /// rotation loop.
660    async fn schedule_dehydration(
661        &self,
662        secret_store: &SecretStore,
663    ) -> Result<(), DehydratedDeviceError> {
664        let key = self
665            .load_key(secret_store, true)
666            .await?
667            .expect("load_key(create_if_missing=true) always yields a key");
668        self.create(None, &key).await?;
669
670        let weak_client = WeakClient::from_client(&self.client);
671        let handle = self
672            .client
673            .task_monitor()
674            .spawn_infinite_task("dehydrated_devices::rotation", async move {
675                loop {
676                    sleep(DEHYDRATION_INTERVAL).await;
677
678                    let Some(client) = weak_client.get() else {
679                        // The client is gone; this task is aborted when its
680                        // handle drops, so just wait for that to happen.
681                        continue;
682                    };
683
684                    client.encryption().dehydrated_devices().rotate_tick().await;
685                }
686            })
687            .abort_on_drop();
688
689        *self.state().rotation_task.lock() = Some(handle);
690        Ok(())
691    }
692
693    /// One iteration of the rotation timer: resolve the cached pickle key and
694    /// upload a fresh dehydrated device, emitting a
695    /// [`DehydratedDeviceEvent::RotationError`] on failure.
696    async fn rotate_tick(&self) {
697        let key = match self.cached_key().await {
698            Ok(Some(key)) => key,
699            Ok(None) => {
700                let msg = "no cached pickle key for dehydrated-device rotation".to_owned();
701                warn!("{msg}; skipping this rotation");
702                self.emit(DehydratedDeviceEvent::RotationError { error: msg });
703                return;
704            }
705            Err(e) => {
706                let msg = e.to_string();
707                warn!(error = %e, "Failed to load cached pickle key for rotation");
708                self.emit(DehydratedDeviceEvent::RotationError { error: msg });
709                return;
710            }
711        };
712
713        if let Err(e) = self.create(None, &key).await {
714            let msg = e.to_string();
715            warn!(error = msg, "Failed to rotate dehydrated device");
716            self.emit(DehydratedDeviceEvent::RotationError { error: msg });
717        }
718    }
719
720    /// Delete the current dehydrated device, if one exists.
721    ///
722    /// Also stops any scheduled rotation, so the next tick will not
723    /// immediately recreate the device the caller just asked to remove.
724    ///
725    /// Returns `Ok(())` silently if no dehydrated device is on the server or
726    /// the server does not implement the endpoint.
727    ///
728    /// # Example
729    ///
730    /// ```no_run
731    /// # use matrix_sdk::Client;
732    /// # async fn example(client: Client) -> anyhow::Result<()> {
733    /// client.encryption().dehydrated_devices().delete().await?;
734    /// # Ok(()) }
735    /// ```
736    #[instrument(skip_all)]
737    pub async fn delete(&self) -> Result<(), DehydratedDeviceError> {
738        self.stop();
739        self.delete_device().await
740    }
741
742    /// Issue the delete request for the current dehydrated device without
743    /// touching the rotation schedule.
744    async fn delete_device(&self) -> Result<(), DehydratedDeviceError> {
745        let request = delete_dehydrated_device::unstable::Request::new();
746        match self.client.send(request).await {
747            Ok(_) => {
748                self.emit(DehydratedDeviceEvent::Deleted);
749                Ok(())
750            }
751            Err(e) => match e.client_api_error_kind() {
752                Some(ErrorKind::Unrecognized) | Some(ErrorKind::NotFound) => Ok(()),
753                _ => Err(e.into()),
754            },
755        }
756    }
757
758    /// Fetch the dehydrated device payload from the server.
759    ///
760    /// Returns `Ok(None)` if the server reports `M_NOT_FOUND` or
761    /// `M_UNRECOGNIZED`.
762    async fn download_device(&self) -> Result<Option<DownloadedDevice>, DehydratedDeviceError> {
763        let request = get_dehydrated_device::unstable::Request::new();
764        match self.client.send(request).await {
765            Ok(response) => Ok(Some(DownloadedDevice {
766                device_id: response.device_id,
767                device_data: response.device_data,
768            })),
769            Err(e) => match e.client_api_error_kind() {
770                Some(ErrorKind::NotFound) | Some(ErrorKind::Unrecognized) => Ok(None),
771                _ => Err(e.into()),
772            },
773        }
774    }
775
776    /// Decrypt the downloaded device and stand up a [`RehydratedDevice`].
777    async fn rehydrate_device(
778        &self,
779        downloaded: &DownloadedDevice,
780        pickle_key: &DehydratedDeviceKey,
781    ) -> Result<RehydratedDevice, DehydratedDeviceError> {
782        let olm = self.client.olm_machine().await;
783        let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
784
785        Ok(machine
786            .dehydrated_devices()
787            .rehydrate(pickle_key, &downloaded.device_id, downloaded.device_data.clone())
788            .await?)
789    }
790
791    /// Drain every queued to-device event from the dehydrated device's
792    /// server-side buffer, feeding each batch through the rehydrated
793    /// machine so the room keys are imported.
794    ///
795    /// An empty batch or an absent cursor ends the drain cleanly; the two
796    /// defensive stops (batch cap, repeated cursor) mark the returned
797    /// [`DrainOutcome`] as truncated instead.
798    async fn absorb_events(
799        &self,
800        device_id: &OwnedDeviceId,
801        rehydrated: &RehydratedDevice,
802    ) -> Result<DrainOutcome, DehydratedDeviceError> {
803        let settings = self.client.decryption_settings();
804
805        let mut next_batch: Option<String> = None;
806        let mut to_device_count: usize = 0;
807        let mut room_key_count: usize = 0;
808
809        let truncated = loop {
810            let mut request = get_events::unstable::Request::new(device_id.clone());
811            request.next_batch.clone_from(&next_batch);
812
813            let response = self.client.send(request).await?;
814            if response.events.is_empty() {
815                break false;
816            }
817
818            to_device_count = to_device_count.saturating_add(response.events.len());
819            let imported = rehydrated.receive_events(response.events, settings).await?;
820            room_key_count = room_key_count.saturating_add(imported.len());
821            trace!(to_device_count, room_key_count, "Absorbed a batch of to-device events");
822            self.emit(DehydratedDeviceEvent::RehydrationProgress {
823                room_keys_imported: room_key_count,
824                to_device_events: to_device_count,
825            });
826
827            if to_device_count >= MAX_TO_DEVICE_EVENTS {
828                warn!(
829                    to_device_count,
830                    "Reached the dehydrated-device drain limit; stopping to bound the loop"
831                );
832                break true;
833            }
834
835            // Guard against a server that repeats the same cursor and would
836            // otherwise keep us looping over an identical batch.
837            match &response.next_batch {
838                None => break false,
839                Some(token) if Some(token) == next_batch.as_ref() => {
840                    warn!(
841                        ?next_batch,
842                        "Server returned the same next_batch twice; stopping to avoid a loop"
843                    );
844                    break true;
845                }
846                Some(token) => next_batch = Some(token.clone()),
847            }
848        };
849
850        info!(
851            to_device_count,
852            room_key_count, truncated, "Finished draining the dehydrated device's to-device queue"
853        );
854        Ok(DrainOutcome {
855            room_keys_imported: room_key_count,
856            to_device_events: to_device_count,
857            truncated,
858        })
859    }
860}
861
862/// Named future returned by [`DehydratedDevices::start`].
863///
864/// Await it to start dehydrated devices with the default behavior, or configure
865/// it first with [`Self::create_new_key`], [`Self::skip_rehydration`], and
866/// [`Self::only_if_key_cached`].
867#[derive(Debug)]
868pub struct StartDehydration<'a> {
869    devices: &'a DehydratedDevices,
870    secret_store: &'a SecretStore,
871    create_new_key: bool,
872    rehydrate: bool,
873    only_if_key_cached: bool,
874    tracing_span: Span,
875}
876
877impl<'a> StartDehydration<'a> {
878    fn new(devices: &'a DehydratedDevices, secret_store: &'a SecretStore) -> Self {
879        Self {
880            devices,
881            secret_store,
882            create_new_key: false,
883            rehydrate: true,
884            only_if_key_cached: false,
885            tracing_span: Span::current(),
886        }
887    }
888
889    /// Generate a fresh random pickle key on start, replacing any existing
890    /// entry in Secret Storage and the local cache.
891    ///
892    /// The reset is suppressed if a rehydration attempt in the same start
893    /// fails, so the stored key can still recover the existing dehydrated
894    /// device on another client.
895    pub fn create_new_key(mut self) -> Self {
896        self.create_new_key = true;
897        self
898    }
899
900    /// Skip the attempt to rehydrate the existing dehydrated device before
901    /// creating the next one.
902    ///
903    /// By default the existing device is rehydrated first.
904    pub fn skip_rehydration(mut self) -> Self {
905        self.rehydrate = false;
906        self
907    }
908
909    /// Do nothing unless a pickle key is already cached locally.
910    ///
911    /// Useful for an opportunistic start on a freshly opened client without
912    /// forcing a Secret Storage unlock.
913    pub fn only_if_key_cached(mut self) -> Self {
914        self.only_if_key_cached = true;
915        self
916    }
917}
918
919impl<'a> IntoFuture for StartDehydration<'a> {
920    type Output = Result<(), DehydratedDeviceError>;
921    boxed_into_future!(extra_bounds: 'a);
922
923    fn into_future(self) -> Self::IntoFuture {
924        let Self {
925            devices,
926            secret_store,
927            create_new_key,
928            rehydrate,
929            only_if_key_cached,
930            tracing_span,
931        } = self;
932
933        let future = async move {
934            if only_if_key_cached && devices.cached_key().await?.is_none() {
935                return Ok(());
936            }
937
938            devices.stop();
939
940            let mut rehydrate_failed = false;
941            if rehydrate
942                && let Some(key) = devices.load_key(secret_store, false).await?
943                && let Err(e) = devices.rehydrate(&key).await
944            {
945                let msg = e.to_string();
946                warn!(error = %e, "Rehydration failed during start; continuing");
947                devices.emit(DehydratedDeviceEvent::RehydrationError { error: msg });
948                rehydrate_failed = true;
949            }
950
951            // Refuse to clobber Secret Storage after a failed rehydration: the
952            // stored pickle key is the only one that can decrypt the existing
953            // dehydrated device, and overwriting it would discard that recovery
954            // chance for every other client signed in to this account.
955            if create_new_key {
956                if rehydrate_failed {
957                    warn!(
958                        "Skipping pickle-key reset after failed rehydration to preserve the chance of recovering the existing dehydrated device on another client"
959                    );
960                } else {
961                    devices.reset_key(secret_store).await?;
962                }
963            }
964
965            devices.schedule_dehydration(secret_store).await
966        };
967
968        Box::pin(future.instrument(tracing_span))
969    }
970}