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