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}