|
15 | 15 | use std::{str::FromStr, sync::Arc}; |
16 | 16 |
|
17 | 17 | 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 | +}; |
20 | 23 | use matrix_sdk_common::{SendOutsideWasm, SyncOutsideWasm}; |
21 | 24 | use ruma::OwnedUserId; |
22 | 25 | use serde::de::Error; |
@@ -457,6 +460,133 @@ pub async fn database_contains_secrets_bundle( |
457 | 460 | }) |
458 | 461 | } |
459 | 462 |
|
| 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 | + |
460 | 590 | #[matrix_sdk_ffi_macros::export] |
461 | 591 | impl Encryption { |
462 | 592 | /// Get the public ed25519 key of our own device. This is usually what is |
@@ -761,6 +891,92 @@ impl Encryption { |
761 | 891 | }) |
762 | 892 | } |
763 | 893 | } |
| 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 | + } |
764 | 980 | } |
765 | 981 |
|
766 | 982 | /// The E2EE identity of a user. |
|
0 commit comments