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}