Skip to content

Commit d912133

Browse files
committed
feat(ffi): expose the dehydrated-device manager to FFI consumers.
Mirrors the SDK shape on the FFI `Encryption` struct with `is_dehydrated_device_supported`, `create_dehydrated_device`, `rehydrate_dehydrated_device`, `delete_dehydrated_device`, `start_dehydrated_devices`, `stop_dehydrated_devices`, and a `dehydrated_device_event_listener` callback interface so Element X (Kotlin and Swift) can drive MSC3814 without bypassing the SDK's lifecycle events. Signed-off-by: Jason Volk <jason@zemos.net>
1 parent fe9086f commit d912133

1 file changed

Lines changed: 218 additions & 2 deletions

File tree

bindings/matrix-sdk-ffi/src/encryption.rs

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
use std::{str::FromStr, sync::Arc};
1616

1717
use futures_util::StreamExt;
18-
use matrix_sdk::encryption::{self, backups, recovery};
19-
use matrix_sdk_base::crypto::types::{BackupSecrets, RoomKeyBackupInfo};
18+
use matrix_sdk::encryption::{self, backups, dehydrated_devices as sdk_dd, recovery, vodozemac};
19+
use matrix_sdk_base::crypto::{
20+
store::types::DehydratedDeviceKey,
21+
types::{BackupSecrets, RoomKeyBackupInfo},
22+
};
2023
use matrix_sdk_common::{SendOutsideWasm, SyncOutsideWasm};
2124
use ruma::OwnedUserId;
2225
use serde::de::Error;
@@ -457,6 +460,133 @@ pub async fn database_contains_secrets_bundle(
457460
})
458461
}
459462

463+
/// Lifecycle event emitted by the dehydrated-device manager.
464+
///
465+
/// Mirrors [`sdk_dd::DehydratedDeviceEvent`]; subscribe via
466+
/// [`Encryption::dehydrated_device_event_listener`].
467+
#[derive(uniffi::Enum)]
468+
pub enum DehydratedDeviceEvent {
469+
/// A fresh dehydrated device was constructed in the local crypto store,
470+
/// before the upload PUT.
471+
Created { device_id: String },
472+
/// The homeserver accepted the upload of the dehydrated device.
473+
Uploaded { device_id: String },
474+
/// The dehydrated device on the homeserver was deleted.
475+
Deleted,
476+
/// A pickle key was cached in the local crypto store.
477+
KeyCached,
478+
/// Rehydration of a dehydrated device began.
479+
RehydrationStarted { device_id: String },
480+
/// A batch of to-device events has been imported during rehydration.
481+
RehydrationProgress { room_keys_imported: u64, to_device_events: u64 },
482+
/// Rehydration finished successfully.
483+
RehydrationCompleted { device_id: String, room_keys_imported: u64, to_device_events: u64 },
484+
/// Rehydration failed.
485+
RehydrationError { error: String },
486+
/// A scheduled rotation tick failed; the rotation task remains scheduled.
487+
RotationError { error: String },
488+
}
489+
490+
impl From<sdk_dd::DehydratedDeviceEvent> for DehydratedDeviceEvent {
491+
fn from(value: sdk_dd::DehydratedDeviceEvent) -> Self {
492+
match value {
493+
sdk_dd::DehydratedDeviceEvent::Created { device_id } => {
494+
Self::Created { device_id: device_id.to_string() }
495+
}
496+
sdk_dd::DehydratedDeviceEvent::Uploaded { device_id } => {
497+
Self::Uploaded { device_id: device_id.to_string() }
498+
}
499+
sdk_dd::DehydratedDeviceEvent::Deleted => Self::Deleted,
500+
sdk_dd::DehydratedDeviceEvent::KeyCached => Self::KeyCached,
501+
sdk_dd::DehydratedDeviceEvent::RehydrationStarted { device_id } => {
502+
Self::RehydrationStarted { device_id: device_id.to_string() }
503+
}
504+
sdk_dd::DehydratedDeviceEvent::RehydrationProgress {
505+
room_keys_imported,
506+
to_device_events,
507+
} => Self::RehydrationProgress {
508+
room_keys_imported: room_keys_imported as u64,
509+
to_device_events: to_device_events as u64,
510+
},
511+
sdk_dd::DehydratedDeviceEvent::RehydrationCompleted {
512+
device_id,
513+
room_keys_imported,
514+
to_device_events,
515+
} => Self::RehydrationCompleted {
516+
device_id: device_id.to_string(),
517+
room_keys_imported: room_keys_imported as u64,
518+
to_device_events: to_device_events as u64,
519+
},
520+
sdk_dd::DehydratedDeviceEvent::RehydrationError { error } => {
521+
Self::RehydrationError { error }
522+
}
523+
sdk_dd::DehydratedDeviceEvent::RotationError { error } => Self::RotationError { error },
524+
}
525+
}
526+
}
527+
528+
#[matrix_sdk_ffi_macros::export(callback_interface)]
529+
pub trait DehydratedDeviceEventListener: SyncOutsideWasm + SendOutsideWasm {
530+
fn on_event(&self, event: DehydratedDeviceEvent);
531+
}
532+
533+
/// Options for [`Encryption::start_dehydrated_devices`].
534+
#[derive(uniffi::Record)]
535+
pub struct StartDehydratedDevicesOpts {
536+
/// Force generation of a fresh random pickle key on start, replacing
537+
/// any existing entry in Secret Storage and the local cache.
538+
pub create_new_key: bool,
539+
/// Whether to attempt to rehydrate the existing dehydrated device, if
540+
/// any, before creating the next one.
541+
pub rehydrate: bool,
542+
/// If `true`, the call becomes a no-op when no pickle key is cached
543+
/// locally.
544+
pub only_if_key_cached: bool,
545+
}
546+
547+
impl From<StartDehydratedDevicesOpts> for sdk_dd::StartDehydrationOpts {
548+
fn from(value: StartDehydratedDevicesOpts) -> Self {
549+
Self {
550+
create_new_key: value.create_new_key,
551+
rehydrate: value.rehydrate,
552+
only_if_key_cached: value.only_if_key_cached,
553+
}
554+
}
555+
}
556+
557+
/// Errors returned by the dehydrated-device FFI surface.
558+
#[derive(Debug, Error, uniffi::Error)]
559+
#[uniffi(flat_error)]
560+
pub enum DehydratedDeviceError {
561+
/// The client is not logged in.
562+
#[error("the client is not logged in")]
563+
NotLoggedIn,
564+
/// The supplied base64-encoded pickle key did not decode to 32 bytes.
565+
#[error("the dehydrated-device pickle key must decode to 32 bytes of base64")]
566+
InvalidPickleKey,
567+
/// Opening Secret Storage with the supplied recovery key failed.
568+
#[error("could not open Secret Storage: {0}")]
569+
SecretStorage(String),
570+
/// Any other failure surfaced by the SDK.
571+
#[error("{0}")]
572+
Sdk(String),
573+
}
574+
575+
impl From<sdk_dd::DehydratedDeviceError> for DehydratedDeviceError {
576+
fn from(value: sdk_dd::DehydratedDeviceError) -> Self {
577+
match value {
578+
sdk_dd::DehydratedDeviceError::NotLoggedIn => Self::NotLoggedIn,
579+
other => Self::Sdk(other.to_string()),
580+
}
581+
}
582+
}
583+
584+
fn decode_pickle_key(base64: &str) -> Result<DehydratedDeviceKey, DehydratedDeviceError> {
585+
let bytes =
586+
vodozemac::base64_decode(base64).map_err(|_| DehydratedDeviceError::InvalidPickleKey)?;
587+
DehydratedDeviceKey::from_slice(&bytes).map_err(|_| DehydratedDeviceError::InvalidPickleKey)
588+
}
589+
460590
#[matrix_sdk_ffi_macros::export]
461591
impl Encryption {
462592
/// Get the public ed25519 key of our own device. This is usually what is
@@ -761,6 +891,92 @@ impl Encryption {
761891
})
762892
}
763893
}
894+
895+
/// Return whether the homeserver advertises support for MSC3814
896+
/// dehydrated devices.
897+
pub async fn is_dehydrated_device_supported(&self) -> Result<bool, DehydratedDeviceError> {
898+
Ok(self.inner.dehydrated_devices().is_supported().await?)
899+
}
900+
901+
/// Build a fresh dehydrated device, encrypt it with the supplied pickle
902+
/// key, and upload it to the homeserver. Returns the new device ID.
903+
///
904+
/// The pickle key is a 32-byte secret, base64 encoded. Callers are
905+
/// responsible for storing the pickle key safely (typically in Secret
906+
/// Storage via [`Encryption::start_dehydrated_devices`]).
907+
pub async fn create_dehydrated_device(
908+
&self,
909+
display_name: Option<String>,
910+
pickle_key: String,
911+
) -> Result<String, DehydratedDeviceError> {
912+
let key = decode_pickle_key(&pickle_key)?;
913+
let id = self.inner.dehydrated_devices().create(display_name.as_deref(), &key).await?;
914+
Ok(id.to_string())
915+
}
916+
917+
/// Rehydrate the dehydrated device currently on the server, if any.
918+
///
919+
/// Returns `true` if a device was rehydrated end to end, `false` if the
920+
/// server reports no dehydrated device or does not implement the endpoint.
921+
pub async fn rehydrate_dehydrated_device(
922+
&self,
923+
pickle_key: String,
924+
) -> Result<bool, DehydratedDeviceError> {
925+
let key = decode_pickle_key(&pickle_key)?;
926+
Ok(self.inner.dehydrated_devices().rehydrate(&key).await?)
927+
}
928+
929+
/// Delete the current dehydrated device, if one exists. Silent if no
930+
/// device is on the server or the server does not implement MSC3814.
931+
pub async fn delete_dehydrated_device(&self) -> Result<(), DehydratedDeviceError> {
932+
Ok(self.inner.dehydrated_devices().delete().await?)
933+
}
934+
935+
/// Start using dehydrated devices for this client, resolving the pickle
936+
/// key through Secret Storage and scheduling weekly rotation.
937+
///
938+
/// The recovery key is consumed (zeroized) after Secret Storage has been
939+
/// unlocked.
940+
pub async fn start_dehydrated_devices(
941+
&self,
942+
mut recovery_key: String,
943+
opts: StartDehydratedDevicesOpts,
944+
) -> Result<(), DehydratedDeviceError> {
945+
let secret_store = self
946+
.inner
947+
.secret_storage()
948+
.open_secret_store(&recovery_key)
949+
.await
950+
.map_err(|e| DehydratedDeviceError::SecretStorage(e.to_string()))?;
951+
recovery_key.zeroize();
952+
self.inner.dehydrated_devices().start(&secret_store, opts.into()).await?;
953+
Ok(())
954+
}
955+
956+
/// Stop the scheduled dehydrated-device rotation.
957+
///
958+
/// Has no effect when no rotation is scheduled. Existing dehydrated
959+
/// devices on the server are left in place; pair with
960+
/// [`Encryption::delete_dehydrated_device`] to remove them.
961+
pub fn stop_dehydrated_devices(&self) {
962+
self.inner.dehydrated_devices().stop();
963+
}
964+
965+
/// Subscribe to lifecycle events emitted by the dehydrated-device
966+
/// manager. The returned [`TaskHandle`] keeps the listener alive; drop
967+
/// it to unsubscribe.
968+
pub fn dehydrated_device_event_listener(
969+
&self,
970+
listener: Box<dyn DehydratedDeviceEventListener>,
971+
) -> Arc<TaskHandle> {
972+
let mut events = Box::pin(self.inner.dehydrated_devices().events());
973+
Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move {
974+
while let Some(event) = events.next().await {
975+
let Ok(event) = event else { continue };
976+
listener.on_event(event.into());
977+
}
978+
})))
979+
}
764980
}
765981

766982
/// The E2EE identity of a user.

0 commit comments

Comments
 (0)