feat(sdk): high-level dehydrated-devices manager (MSC3814).

Adds `Encryption::dehydrated_devices()` returning a manager with
`is_supported`, `create`, `rehydrate`, `delete`, `start`, and `stop`,
plus a Secret Storage round trip for the pickle key, a broadcast
event channel for lifecycle observability, and a weekly rotation
task. The module is gated behind `e2e-encryption` and follows the
matrix-js-sdk `DehydratedDeviceManager` shape so applications can
adopt MSC3814 without writing the wire-glue themselves.

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk
2026-05-22 09:51:01 +00:00
committed by Damir Jelić
parent c0884ea864
commit cc94eea006
2 changed files with 809 additions and 0 deletions

View File

@@ -0,0 +1,792 @@
// Copyright 2026 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! High-level interface for [Dehydrated Devices] ([MSC3814]).
//!
//! A dehydrated device is a virtual device the homeserver keeps on the user's
//! behalf while no live device is online. Senders can encrypt to it using the
//! same Olm session establishment as for any other device. When a new device
//! comes online it rehydrates: it pulls the private parts of the virtual
//! device back down, decrypts them with a pickle key, drains the queued
//! to-device events, and imports the room keys they carry.
//!
//! # Lifecycle
//!
//! 1. `is_supported`: cheap probe of the homeserver.
//! 2. `create`: build a fresh dehydrated device and upload it. The pickle key
//! is supplied by the caller; storage and rotation of the pickle key are an
//! application concern.
//! 3. `rehydrate`: pull the existing dehydrated device, decrypt with the pickle
//! key, absorb queued to-device events, and delete the device.
//! 4. `delete`: remove the current dehydrated device without rehydrating.
//!
//! # Example
//!
//! ```no_run
//! # use matrix_sdk::Client;
//! # use matrix_sdk_base::crypto::store::types::DehydratedDeviceKey;
//! # async fn example(client: Client) -> anyhow::Result<()> {
//! let dehydrated = client.encryption().dehydrated_devices();
//!
//! if !dehydrated.is_supported().await? {
//! return Ok(());
//! }
//!
//! let pickle_key = DehydratedDeviceKey::new();
//!
//! dehydrated.rehydrate(&pickle_key).await?;
//! dehydrated.create(None, &pickle_key).await?;
//! # Ok(())
//! # }
//! ```
//!
//! [Dehydrated Devices]: https://spec.matrix.org/unstable/client-server-api/#dehydrated-devices
//! [MSC3814]: https://github.com/matrix-org/matrix-spec-proposals/pull/3814
use std::time::Duration;
use futures_core::Stream;
use matrix_sdk_base::crypto::{
DecryptionSettings, OlmError, TrustRequirement,
dehydrated_devices::{DehydrationError, RehydratedDevice},
store::types::DehydratedDeviceKey,
vodozemac::base64_decode,
};
use matrix_sdk_common::{executor::spawn, locks::Mutex as StdMutex, sleep::sleep};
use ruma::{
OwnedDeviceId,
api::{
client::dehydrated_device::{
DehydratedDeviceData, delete_dehydrated_device, get_dehydrated_device, get_events,
},
error::ErrorKind,
},
events::secret::request::SecretName,
serde::Raw,
};
use thiserror::Error;
use tokio::sync::broadcast;
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
use tracing::{debug, info, instrument, trace, warn};
use zeroize::Zeroizing;
use crate::{
Client, HttpError,
client::WeakClient,
encryption::{CryptoStoreError, secret_storage::SecretStore},
executor::JoinHandle,
};
/// The default display name uploaded for a freshly created dehydrated device.
const DEFAULT_DEVICE_DISPLAY_NAME: &str = "Dehydrated device";
/// The name used to store the dehydrated-device pickle key in Secret Storage.
///
/// MSC3814 reserves `m.dehydrated_device` for the stable name; this is the
/// unstable equivalent the implementation will publish until the MSC
/// stabilizes.
const PICKLE_KEY_SECRET_NAME: &str = "org.matrix.msc3814";
/// How often [`DehydratedDevices::start`] rotates the dehydrated device.
///
/// Set to one week, matching matrix-js-sdk's `DEHYDRATION_INTERVAL`.
pub const DEHYDRATION_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// Errors that can occur while managing dehydrated devices.
#[derive(Debug, Error)]
pub enum DehydratedDeviceError {
/// The HTTP request to the homeserver failed.
#[error(transparent)]
Http(#[from] HttpError),
/// The cryptographic operation on the dehydrated device failed.
#[error(transparent)]
Crypto(#[from] DehydrationError),
/// Importing room keys from a rehydrated device's to-device events
/// failed.
#[error(transparent)]
Olm(#[from] OlmError),
/// The crypto store could not be accessed.
#[error(transparent)]
Store(#[from] CryptoStoreError),
/// Reading or writing a secret to Secret Storage failed.
#[error(transparent)]
SecretStorage(#[from] crate::encryption::secret_storage::SecretStorageError),
/// The pickle key stored in Secret Storage was not valid base64.
#[error("the dehydrated-device pickle key in Secret Storage is not valid base64: {0}")]
PickleKeyDecode(#[from] vodozemac::Base64DecodeError),
/// The client is not logged in; the Olm machine is not available.
#[error("the client is not logged in")]
NotLoggedIn,
}
/// Return a [`SecretName`] for the dehydrated-device pickle key entry.
fn pickle_key_secret_name() -> SecretName {
SecretName::from(PICKLE_KEY_SECRET_NAME)
}
/// Lifecycle events emitted by [`DehydratedDevices`].
///
/// Subscribe with [`DehydratedDevices::events`] to observe creation,
/// rehydration progress, and rotation outcomes. The set of variants is a
/// superset of matrix-js-sdk's `CryptoEvent::Dehydrated*` and
/// `CryptoEvent::Rehydration*`: [`Self::Created`], [`Self::Uploaded`],
/// [`Self::RehydrationStarted`], [`Self::RehydrationProgress`], and
/// [`Self::RehydrationError`] mirror js-sdk one for one. The remaining
/// variants are Rust-side extensions retained because callers asked for
/// them in code review: [`Self::Deleted`] signals the post-rehydration
/// delete, [`Self::RehydrationCompleted`] carries the imported counts so
/// the caller does not have to fold over progress events, and
/// [`Self::RotationError`] surfaces background rotation failures the
/// task otherwise swallows.
#[derive(Clone, Debug)]
pub enum DehydratedDeviceEvent {
/// A fresh dehydrated device was constructed in the local crypto
/// store, before the upload PUT.
Created {
/// Device ID assigned to the new dehydrated device.
device_id: OwnedDeviceId,
},
/// The dehydrated device announced by the preceding
/// [`Self::Created`] event was accepted by the homeserver.
Uploaded {
/// Device ID of the dehydrated device now visible on the server.
device_id: OwnedDeviceId,
},
/// The dehydrated device currently on the server was deleted.
Deleted,
/// A pickle key was cached in the local crypto store.
KeyCached,
/// Rehydration of a dehydrated device began.
RehydrationStarted {
/// Device ID of the dehydrated device being rehydrated.
device_id: OwnedDeviceId,
},
/// A batch of to-device events has been imported during rehydration.
RehydrationProgress {
/// Cumulative number of room keys imported so far.
room_keys_imported: usize,
/// Cumulative number of to-device events processed so far.
to_device_events: usize,
},
/// Rehydration finished successfully.
RehydrationCompleted {
/// Device ID of the rehydrated device.
device_id: OwnedDeviceId,
/// Total number of room keys imported.
room_keys_imported: usize,
/// Total number of to-device events processed.
to_device_events: usize,
},
/// Rehydration failed before it could complete.
RehydrationError {
/// Human-readable description of the failure.
error: String,
},
/// A scheduled rotation tick failed; the rotation task remains
/// scheduled and will retry at the next tick.
RotationError {
/// Human-readable description of the failure.
error: String,
},
}
/// Options for [`DehydratedDevices::start`].
#[derive(Clone, Debug)]
pub struct StartDehydrationOpts {
/// Force generation of a fresh random pickle key on start, replacing
/// any existing entry in Secret Storage and the local cache.
pub create_new_key: bool,
/// Whether to attempt to rehydrate the existing dehydrated device, if
/// any, before creating the next one. Defaults to `true`.
pub rehydrate: bool,
/// If `true`, [`DehydratedDevices::start`] becomes a no-op when no
/// pickle key is cached locally. Useful for opportunistic restart on
/// a freshly opened client without forcing a Secret Storage unlock.
pub only_if_key_cached: bool,
}
impl Default for StartDehydrationOpts {
fn default() -> Self {
Self { create_new_key: false, rehydrate: true, only_if_key_cached: false }
}
}
/// Process-wide state for the dehydrated-devices manager.
///
/// Held inside [`crate::encryption::EncryptionData`] so the event sender
/// and any in-flight rotation task survive across
/// `Client::encryption().dehydrated_devices()` calls.
pub(crate) struct DehydratedDevicesState {
event_sender: broadcast::Sender<DehydratedDeviceEvent>,
rotation_task: StdMutex<Option<DehydratedDeviceRotationTask>>,
/// The ID of the dehydrated device most recently uploaded by this
/// process. Used by [`DehydratedDevices::rehydrate`] to detect a
/// server that serves a different (potentially attacker-replayed)
/// device id within the same session. Lost on restart; cross-session
/// continuity is not enforced.
last_uploaded_device_id: StdMutex<Option<OwnedDeviceId>>,
}
impl Default for DehydratedDevicesState {
fn default() -> Self {
let (event_sender, _) = broadcast::channel(32);
Self {
event_sender,
rotation_task: StdMutex::new(None),
last_uploaded_device_id: StdMutex::new(None),
}
}
}
/// Whether the rotation timer should keep running after a tick.
enum RotationTickOutcome {
/// The rotation timer should remain scheduled and try again at the next
/// interval. Used both for successful ticks and for recoverable HTTP or
/// store errors.
Continue,
/// The rotation timer must abort. Used when the cached pickle key is
/// gone; restarting the timer requires a fresh [`DehydratedDevices::start`]
/// call.
Halt,
}
/// The background task that re-creates the dehydrated device on a fixed
/// interval. Aborted on drop.
struct DehydratedDeviceRotationTask {
#[cfg_attr(target_family = "wasm", allow(dead_code))]
join_handle: JoinHandle<()>,
}
impl Drop for DehydratedDeviceRotationTask {
fn drop(&mut self) {
#[cfg(not(target_family = "wasm"))]
self.join_handle.abort();
}
}
/// High-level handle returned by
/// [`Encryption::dehydrated_devices`](crate::encryption::Encryption::dehydrated_devices).
#[derive(Debug, Clone)]
pub struct DehydratedDevices {
pub(super) client: Client,
}
/// The dehydrated device the server currently holds on the user's behalf.
struct DownloadedDevice {
device_id: OwnedDeviceId,
device_data: Raw<DehydratedDeviceData>,
}
impl DehydratedDevices {
/// Subscribe to [`DehydratedDeviceEvent`]s.
///
/// Each call returns a fresh stream. If a subscriber is slow enough to
/// fall behind the channel's buffer, it receives a
/// [`BroadcastStreamRecvError`] reporting the number of skipped events
/// and the stream continues from the most recent event.
pub fn events(
&self,
) -> impl Stream<Item = Result<DehydratedDeviceEvent, BroadcastStreamRecvError>> + use<> {
BroadcastStream::new(self.state().event_sender.subscribe())
}
fn state(&self) -> &DehydratedDevicesState {
&self.client.inner.e2ee.dehydrated_devices_state
}
fn emit(&self, event: DehydratedDeviceEvent) {
// A send failure means there are no subscribers; that is fine.
let _ = self.state().event_sender.send(event);
}
/// Return whether the homeserver advertises dehydrated-device support.
///
/// Probes by issuing `GET /dehydrated_device` and inspecting the errcode
/// of the response:
///
/// - `M_UNRECOGNIZED` means the server does not understand the endpoint.
/// - `M_NOT_FOUND` or a successful response means the server understands
/// the endpoint (whether or not the user currently has a dehydrated
/// device on file).
///
/// Any other transport or API failure is propagated.
#[instrument(skip_all)]
pub async fn is_supported(&self) -> Result<bool, DehydratedDeviceError> {
let request = get_dehydrated_device::unstable::Request::new();
match self.client.send(request).await {
Ok(_) => Ok(true),
Err(e) => match e.client_api_error_kind() {
Some(ErrorKind::Unrecognized) => Ok(false),
Some(ErrorKind::NotFound) => Ok(true),
_ => Err(e.into()),
},
}
}
/// Create a fresh dehydrated device and upload it to the homeserver.
///
/// The pickle key is used by [vodozemac] to encrypt the private parts of
/// the device. The application is responsible for safely storing the
/// pickle key (typically in Secret Storage so future sessions can
/// rehydrate the device).
///
/// # Arguments
///
/// * `display_name` - Optional human-readable name uploaded as the
/// dehydrated device's `initial_device_display_name`. Defaults to
/// `"Dehydrated device"` to match the existing [matrix-js-sdk] behavior.
/// * `pickle_key` - 32-byte key used to encrypt the dehydrated device.
///
/// [vodozemac]: https://docs.rs/vodozemac/
/// [matrix-js-sdk]: https://github.com/matrix-org/matrix-js-sdk
#[instrument(skip_all)]
pub async fn create(
&self,
display_name: Option<&str>,
pickle_key: &DehydratedDeviceKey,
) -> Result<OwnedDeviceId, DehydratedDeviceError> {
let olm = self.client.olm_machine().await;
let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
debug!("Creating a new dehydrated device in the crypto store");
let dehydrated_device = machine.dehydrated_devices().create().await?;
let display_name = display_name.unwrap_or(DEFAULT_DEVICE_DISPLAY_NAME);
let request =
dehydrated_device.keys_for_upload(display_name.to_owned(), pickle_key).await?;
let device_id = request.device_id.clone();
self.emit(DehydratedDeviceEvent::Created { device_id: device_id.clone() });
debug!(?device_id, "Uploading dehydrated device to the homeserver");
self.client.send(request).await?;
info!(?device_id, "Successfully uploaded dehydrated device");
*self.state().last_uploaded_device_id.lock() = Some(device_id.clone());
self.emit(DehydratedDeviceEvent::Uploaded { device_id: device_id.clone() });
Ok(device_id)
}
/// Rehydrate the dehydrated device currently on the server, if any.
///
/// Downloads the dehydrated device, decrypts it with `pickle_key`,
/// drains all queued to-device events to import their room keys, and
/// finally deletes the device from the server.
///
/// Returns `Ok(false)` if the server reports no dehydrated device
/// (`M_NOT_FOUND`) or does not implement the endpoint
/// (`M_UNRECOGNIZED`). Returns `Ok(true)` once the rehydration cycle
/// has completed end to end.
#[instrument(skip_all)]
pub async fn rehydrate(
&self,
pickle_key: &DehydratedDeviceKey,
) -> Result<bool, DehydratedDeviceError> {
let Some(downloaded) = self.download_device().await? else { return Ok(false) };
info!(device_id = ?downloaded.device_id, "Dehydrated device found");
// Within this process, the server should serve back whichever id we
// last uploaded. A mismatch can indicate a stale or replayed payload;
// warn so the application can decide to drop or quarantine the
// imported keys. Cross-restart continuity is not enforced because the
// last-uploaded id is not persisted to the crypto store.
if let Some(expected) = self.state().last_uploaded_device_id.lock().clone()
&& expected != downloaded.device_id
{
warn!(
?expected,
got = ?downloaded.device_id,
"Server returned a different dehydrated-device id than the one we last uploaded; continuing but the payload may be stale"
);
}
self.emit(DehydratedDeviceEvent::RehydrationStarted {
device_id: downloaded.device_id.clone(),
});
let rehydrated = self.rehydrate_device(&downloaded, pickle_key).await?;
let (room_keys_imported, to_device_events) =
self.absorb_events(&downloaded.device_id, &rehydrated).await?;
self.emit(DehydratedDeviceEvent::RehydrationCompleted {
device_id: downloaded.device_id.clone(),
room_keys_imported,
to_device_events,
});
// Key import already succeeded; if the post-drain delete fails, log
// it but do not let the failure masquerade as a rehydration error.
// The next create() call will replace the device anyway.
if let Err(e) = self.delete().await {
warn!(device_id = ?downloaded.device_id, error = %e, "Post-rehydration delete failed; the next rotation will replace the device");
}
Ok(true)
}
/// Cache the pickle key in the local crypto store.
///
/// Subsequent rehydration attempts can then resolve the key from the
/// cache without an account-data round-trip. Equivalent to
/// matrix-js-sdk's `DehydratedDeviceManager.cacheKey`.
#[instrument(skip_all)]
pub(crate) async fn cache_key(
&self,
pickle_key: &DehydratedDeviceKey,
) -> Result<(), DehydratedDeviceError> {
let olm = self.client.olm_machine().await;
let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
machine.dehydrated_devices().save_dehydrated_device_pickle_key(pickle_key).await?;
self.emit(DehydratedDeviceEvent::KeyCached);
Ok(())
}
/// Return the pickle key currently cached in the local crypto store.
///
/// `Ok(None)` if no key has been cached. The returned key matches the
/// last value persisted via [`cache_key`](Self::cache_key) or
/// [`reset_key`](Self::reset_key); it is not fetched from Secret Storage.
#[instrument(skip_all)]
pub(crate) async fn cached_key(
&self,
) -> Result<Option<DehydratedDeviceKey>, DehydratedDeviceError> {
let olm = self.client.olm_machine().await;
let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
Ok(machine.dehydrated_devices().get_dehydrated_device_pickle_key().await?)
}
/// Return whether the pickle key is stored in the given Secret Storage.
///
/// The key is looked up by the account-data event type
/// `org.matrix.msc3814` (the unstable name reserved by MSC3814).
pub async fn is_key_stored(
&self,
secret_store: &SecretStore,
) -> Result<bool, DehydratedDeviceError> {
Ok(secret_store.get_secret(pickle_key_secret_name()).await?.is_some())
}
/// Generate a new random pickle key, persist it in Secret Storage, and
/// cache it in the local crypto store.
///
/// The previous key (if any) is overwritten in both places. Any
/// dehydrated device that was encrypted with the previous key becomes
/// unrehydratable until rotated.
#[instrument(skip_all)]
pub async fn reset_key(
&self,
secret_store: &SecretStore,
) -> Result<DehydratedDeviceKey, DehydratedDeviceError> {
let key = DehydratedDeviceKey::new();
secret_store.put_secret(pickle_key_secret_name(), &key.to_base64()).await?;
self.cache_key(&key).await?;
Ok(key)
}
/// Resolve the pickle key.
///
/// Looks for the key in this order:
///
/// 1. The local crypto-store cache.
/// 2. The provided Secret Storage account-data entry. A successfully
/// fetched key is written back to the cache.
/// 3. If `create_if_missing`, a fresh random key is generated, stored, and
/// cached.
///
/// Returns `Ok(None)` only when the key is absent from both sources and
/// `create_if_missing` is `false`.
#[instrument(skip_all)]
pub(crate) async fn load_key(
&self,
secret_store: &SecretStore,
create_if_missing: bool,
) -> Result<Option<DehydratedDeviceKey>, DehydratedDeviceError> {
if let Some(cached) = self.cached_key().await? {
return Ok(Some(cached));
}
let Some(base64) = secret_store.get_secret(pickle_key_secret_name()).await? else {
return if create_if_missing {
Ok(Some(self.reset_key(secret_store).await?))
} else {
Ok(None)
};
};
let bytes = Zeroizing::new(base64_decode(&base64)?);
let key = DehydratedDeviceKey::from_slice(&bytes)?;
self.cache_key(&key).await?;
Ok(Some(key))
}
/// Start using dehydrated devices for this client.
///
/// The caller is expected to have unlocked Secret Storage and bootstrapped
/// cross-signing before this call; otherwise the underlying account-data
/// reads and writes will fail mid-flight.
///
/// Mirrors matrix-js-sdk's `DehydratedDeviceManager.start`:
///
/// 1. If `opts.only_if_key_cached` is set, return early when no pickle key
/// is cached locally.
/// 2. Stop any previously scheduled rotation.
/// 3. If `opts.rehydrate`, attempt to rehydrate the existing dehydrated
/// device. Failures are logged and emitted as
/// [`DehydratedDeviceEvent::RehydrationError`] but do not abort `start`.
/// 4. If `opts.create_new_key` *and* the rehydration step succeeded (or was
/// skipped), replace the pickle key in Secret Storage with a fresh
/// random one. A failed rehydration suppresses the reset so the stored
/// key can still recover the existing dehydrated device on another
/// client.
/// 5. Create a new dehydrated device now and schedule rotation every
/// [`DEHYDRATION_INTERVAL`].
///
/// The rotation task resolves the pickle key from the local crypto
/// store on each tick. The local cache is the only key source available
/// to the task because reopening Secret Storage requires the recovery
/// key, which is not retained. If the cache is cleared while the task
/// is scheduled, the task emits
/// [`DehydratedDeviceEvent::RotationError`] and stops; restart it with
/// a fresh [`Self::start`] call once the key is available again.
/// Per-tick HTTP failures emit
/// [`DehydratedDeviceEvent::RotationError`] without aborting the
/// schedule.
#[instrument(skip_all)]
pub async fn start(
&self,
secret_store: &SecretStore,
opts: StartDehydrationOpts,
) -> Result<(), DehydratedDeviceError> {
if opts.only_if_key_cached && self.cached_key().await?.is_none() {
return Ok(());
}
self.stop();
let mut rehydrate_failed = false;
if opts.rehydrate
&& let Some(key) = self.load_key(secret_store, false).await?
&& let Err(e) = self.rehydrate(&key).await
{
let msg = e.to_string();
warn!(error = %e, "Rehydration failed during start; continuing");
self.emit(DehydratedDeviceEvent::RehydrationError { error: msg });
rehydrate_failed = true;
}
// Refuse to clobber Secret Storage after a failed rehydration: the
// stored pickle key is the only one that can decrypt the existing
// dehydrated device, and overwriting it would discard that recovery
// chance for every other client signed in to this account.
if opts.create_new_key {
if rehydrate_failed {
warn!(
"Skipping pickle-key reset after failed rehydration to preserve the chance of recovering the existing dehydrated device on another client"
);
} else {
self.reset_key(secret_store).await?;
}
}
self.schedule_dehydration(secret_store).await
}
/// Stop the scheduled dehydrated-device rotation, if any.
///
/// Has no effect when no rotation is scheduled. Existing dehydrated
/// devices on the server are left in place; pair with
/// [`Self::delete`] to clean those up.
pub fn stop(&self) {
self.state().rotation_task.lock().take();
}
/// Create-and-upload the first dehydrated device now, then spawn the
/// rotation loop.
async fn schedule_dehydration(
&self,
secret_store: &SecretStore,
) -> Result<(), DehydratedDeviceError> {
let key = self
.load_key(secret_store, true)
.await?
.expect("load_key(create_if_missing=true) always yields a key");
self.create(None, &key).await?;
let weak_client = WeakClient::from_client(&self.client);
let join_handle = spawn(async move {
loop {
sleep(DEHYDRATION_INTERVAL).await;
let Some(client) = weak_client.get() else {
debug!("Client dropped; halting dehydrated-device rotation");
return;
};
match client.encryption().dehydrated_devices().rotate_tick().await {
RotationTickOutcome::Continue => {}
RotationTickOutcome::Halt => return,
}
}
});
*self.state().rotation_task.lock() = Some(DehydratedDeviceRotationTask { join_handle });
Ok(())
}
/// One iteration of the rotation timer: resolve the cached pickle key,
/// upload a fresh dehydrated device, and report whether the timer should
/// keep running.
async fn rotate_tick(&self) -> RotationTickOutcome {
let key = match self.cached_key().await {
Ok(Some(key)) => key,
Ok(None) => {
let msg = "no cached pickle key for dehydrated-device rotation".to_owned();
warn!("{msg}; halting timer until start() is called again");
self.emit(DehydratedDeviceEvent::RotationError { error: msg });
return RotationTickOutcome::Halt;
}
Err(e) => {
let msg = e.to_string();
warn!(error = %e, "Failed to load cached pickle key for rotation");
self.emit(DehydratedDeviceEvent::RotationError { error: msg });
return RotationTickOutcome::Continue;
}
};
if let Err(e) = self.create(None, &key).await {
let msg = e.to_string();
warn!(error = msg, "Failed to rotate dehydrated device");
self.emit(DehydratedDeviceEvent::RotationError { error: msg });
}
RotationTickOutcome::Continue
}
/// Delete the current dehydrated device, if one exists.
///
/// Also stops any scheduled rotation, so the next tick will not
/// immediately recreate the device the caller just asked to remove.
///
/// Returns `Ok(())` silently if no dehydrated device is on the server or
/// the server does not implement the endpoint, matching the
/// matrix-js-sdk's behavior.
#[instrument(skip_all)]
pub async fn delete(&self) -> Result<(), DehydratedDeviceError> {
self.stop();
let request = delete_dehydrated_device::unstable::Request::new();
match self.client.send(request).await {
Ok(_) => {
self.emit(DehydratedDeviceEvent::Deleted);
Ok(())
}
Err(e) => match e.client_api_error_kind() {
Some(ErrorKind::Unrecognized) | Some(ErrorKind::NotFound) => Ok(()),
_ => Err(e.into()),
},
}
}
/// Fetch the dehydrated device payload from the server.
///
/// Returns `Ok(None)` if the server reports `M_NOT_FOUND` or
/// `M_UNRECOGNIZED`.
async fn download_device(&self) -> Result<Option<DownloadedDevice>, DehydratedDeviceError> {
let request = get_dehydrated_device::unstable::Request::new();
match self.client.send(request).await {
Ok(response) => Ok(Some(DownloadedDevice {
device_id: response.device_id,
device_data: response.device_data,
})),
Err(e) => match e.client_api_error_kind() {
Some(ErrorKind::NotFound) | Some(ErrorKind::Unrecognized) => Ok(None),
_ => Err(e.into()),
},
}
}
/// Decrypt the downloaded device and stand up a [`RehydratedDevice`].
async fn rehydrate_device(
&self,
downloaded: &DownloadedDevice,
pickle_key: &DehydratedDeviceKey,
) -> Result<RehydratedDevice, DehydratedDeviceError> {
let olm = self.client.olm_machine().await;
let machine = olm.as_ref().ok_or(DehydratedDeviceError::NotLoggedIn)?;
Ok(machine
.dehydrated_devices()
.rehydrate(pickle_key, &downloaded.device_id, downloaded.device_data.clone())
.await?)
}
/// Drain every queued to-device event from the dehydrated device's
/// server-side buffer, feeding each batch through the rehydrated
/// machine so the room keys are imported. Returns
/// `(room_keys_imported, to_device_events_processed)`.
async fn absorb_events(
&self,
device_id: &OwnedDeviceId,
rehydrated: &RehydratedDevice,
) -> Result<(usize, usize), DehydratedDeviceError> {
let settings =
DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted };
let mut next_batch: Option<String> = None;
let mut to_device_count: usize = 0;
let mut room_key_count: usize = 0;
loop {
let mut request = get_events::unstable::Request::new(device_id.clone());
request.next_batch.clone_from(&next_batch);
let response = self.client.send(request).await?;
if response.events.is_empty() {
break;
}
to_device_count += response.events.len();
let imported = rehydrated.receive_events(response.events, &settings).await?;
room_key_count += imported.len();
trace!(to_device_count, room_key_count, "Absorbed a batch of to-device events");
self.emit(DehydratedDeviceEvent::RehydrationProgress {
room_keys_imported: room_key_count,
to_device_events: to_device_count,
});
// Defensive guard against a server that keeps returning the
// same cursor or stops returning one altogether. matrix-js-sdk
// does not have this; added here as a safety net.
match response.next_batch {
None => break,
Some(ref token) if Some(token) == next_batch.as_ref() => {
warn!(
?next_batch,
"Server returned the same next_batch twice; aborting to avoid an infinite loop"
);
break;
}
Some(token) => next_batch = Some(token),
}
}
info!(to_device_count, room_key_count, "Drained dehydrated device to-device queue");
Ok((room_key_count, to_device_count))
}
}

View File

@@ -106,6 +106,7 @@ use crate::{
};
pub mod backups;
pub mod dehydrated_devices;
pub mod futures;
pub mod identities;
pub mod recovery;
@@ -207,6 +208,10 @@ pub(crate) struct EncryptionData {
/// All state related to secret storage recovery.
pub recovery_state: SharedObservable<RecoveryState>,
/// State for the dehydrated-devices manager (event channel, scheduled
/// rotation task).
pub dehydrated_devices_state: dehydrated_devices::DehydratedDevicesState,
}
impl EncryptionData {
@@ -217,6 +222,7 @@ impl EncryptionData {
tasks: StdMutex::new(Default::default()),
backup_state: Default::default(),
recovery_state: Default::default(),
dehydrated_devices_state: Default::default(),
}
}
@@ -1804,6 +1810,17 @@ impl Encryption {
Recovery { client: self.client.to_owned() }
}
/// Get the dehydrated-devices manager of the client.
///
/// A dehydrated device is a virtual device that the homeserver holds on
/// the user's behalf and that can receive end-to-end encrypted to-device
/// events while the user is offline. See the
/// [`dehydrated_devices`] module
/// for the full lifecycle and an example.
pub fn dehydrated_devices(&self) -> dehydrated_devices::DehydratedDevices {
dehydrated_devices::DehydratedDevices { client: self.client.to_owned() }
}
/// Enables the crypto-store cross-process lock.
///
/// This may be required if there are multiple processes that may do writes