diff --git a/bindings/matrix-sdk-ffi/src/encryption.rs b/bindings/matrix-sdk-ffi/src/encryption.rs index 46b4f7424ee..d85498758c3 100644 --- a/bindings/matrix-sdk-ffi/src/encryption.rs +++ b/bindings/matrix-sdk-ffi/src/encryption.rs @@ -9,7 +9,7 @@ use thiserror::Error; use zeroize::Zeroize; use super::RUNTIME; -use crate::{client::Client, error::ClientError, task_handle::TaskHandle}; +use crate::{client::Client, error::ClientError, ruma::AuthData, task_handle::TaskHandle}; #[derive(uniffi::Object)] pub struct Encryption { @@ -357,6 +357,22 @@ impl Encryption { Ok(result?) } + /// Completely reset the current user's crypto identity: reset the cross + /// signing keys, delete the existing backup and recovery key. + pub async fn reset_identity(&self) -> Result>, ClientError> { + if let Some(reset_handle) = self + .inner + .recovery() + .reset_identity() + .await + .map_err(|e| ClientError::Generic { msg: e.to_string() })? + { + return Ok(Some(Arc::new(IdentityResetHandle { inner: reset_handle }))); + } + + Ok(None) + } + pub async fn recover(&self, mut recovery_key: String) -> Result<()> { let result = self.inner.recovery().recover(&recovery_key).await; @@ -387,3 +403,70 @@ impl Encryption { self.inner.wait_for_e2ee_initialization_tasks().await; } } + +#[derive(uniffi::Object)] +pub struct IdentityResetHandle { + pub(crate) inner: matrix_sdk::encryption::recovery::IdentityResetHandle, +} + +#[uniffi::export(async_runtime = "tokio")] +impl IdentityResetHandle { + /// Get the underlying [`CrossSigningResetAuthType`] this identity reset + /// process is using. + pub fn auth_type(&self) -> CrossSigningResetAuthType { + self.inner.auth_type().into() + } + + /// This method starts the identity reset process and + /// will go through the following steps: + /// + /// 1. Disable backing up room keys and delete the active backup + /// 2. Disable recovery and delete secret storage + /// 3. Go through the cross-signing key reset flow + /// 4. Finally, re-enable key backups only if they were enabled before + pub async fn reset(&self, auth: Option) -> Result<(), ClientError> { + if let Some(auth) = auth { + self.inner + .reset(Some(auth.into())) + .await + .map_err(|e| ClientError::Generic { msg: e.to_string() }) + } else { + self.inner.reset(None).await.map_err(|e| ClientError::Generic { msg: e.to_string() }) + } + } +} + +#[derive(uniffi::Enum)] +pub enum CrossSigningResetAuthType { + /// The homeserver requires user-interactive authentication. + Uiaa, + // /// OIDC is used for authentication and the user needs to open a URL to + // /// approve the upload of cross-signing keys. + Oidc { + info: OidcCrossSigningResetInfo, + }, +} + +impl From<&matrix_sdk::encryption::CrossSigningResetAuthType> for CrossSigningResetAuthType { + fn from(value: &matrix_sdk::encryption::CrossSigningResetAuthType) -> Self { + match value { + encryption::CrossSigningResetAuthType::Uiaa(_) => Self::Uiaa, + encryption::CrossSigningResetAuthType::Oidc(info) => Self::Oidc { info: info.into() }, + } + } +} + +#[derive(uniffi::Record)] +pub struct OidcCrossSigningResetInfo { + /// The error message we received from the homeserver after we attempted to + /// reset the cross-signing keys. + pub error: String, + /// The URL where the user can approve the reset of the cross-signing keys. + pub approval_url: String, +} + +impl From<&matrix_sdk::encryption::OidcCrossSigningResetInfo> for OidcCrossSigningResetInfo { + fn from(value: &matrix_sdk::encryption::OidcCrossSigningResetInfo) -> Self { + Self { error: value.error.to_owned(), approval_url: value.approval_url.to_string() } + } +} diff --git a/bindings/matrix-sdk-ffi/src/ruma.rs b/bindings/matrix-sdk-ffi/src/ruma.rs index f54be0581e9..624e282d018 100644 --- a/bindings/matrix-sdk-ffi/src/ruma.rs +++ b/bindings/matrix-sdk-ffi/src/ruma.rs @@ -57,6 +57,36 @@ use crate::{ utils::u64_to_uint, }; +#[derive(uniffi::Enum)] +pub enum AuthData { + /// Password-based authentication (`m.login.password`). + Password { password_details: AuthDataPasswordDetails }, +} + +#[derive(uniffi::Record)] +pub struct AuthDataPasswordDetails { + /// One of the user's identifiers. + identifier: String, + + /// The plaintext password. + password: String, +} + +impl From for ruma::api::client::uiaa::AuthData { + fn from(value: AuthData) -> ruma::api::client::uiaa::AuthData { + match value { + AuthData::Password { password_details } => { + let user_id = ruma::UserId::parse(password_details.identifier).unwrap(); + + ruma::api::client::uiaa::AuthData::Password(ruma::api::client::uiaa::Password::new( + user_id.into(), + password_details.password, + )) + } + } + } +} + /// Parse a matrix entity from a given URI, be it either /// a `matrix.to` link or a `matrix:` URI #[uniffi::export] diff --git a/crates/matrix-sdk/src/encryption/recovery/mod.rs b/crates/matrix-sdk/src/encryption/recovery/mod.rs index 3c37c495e41..e2f39416c48 100644 --- a/crates/matrix-sdk/src/encryption/recovery/mod.rs +++ b/crates/matrix-sdk/src/encryption/recovery/mod.rs @@ -115,6 +115,7 @@ use self::{ futures::{Enable, RecoverAndReset, Reset}, types::{BackupDisabledContent, SecretStorageDisabledContent}, }; +use crate::encryption::{AuthData, CrossSigningResetAuthType, CrossSigningResetHandle}; /// The recovery manager for the [`Client`]. #[derive(Debug)] @@ -344,6 +345,79 @@ impl Recovery { RecoverAndReset::new(self, old_key) } + /// Completely reset the current user's crypto identity. + /// This method will go through the following steps: + /// + /// 1. Disable backing up room keys and delete the active backup + /// 2. Disable recovery and delete secret storage + /// 3. Go through the cross-signing key reset flow + /// 4. Finally, re-enable key backups (only if they were already enabled) + /// + /// Disclaimer: failures in this flow will potentially leave the user in + /// an inconsistent state but they're expected to just run the reset flow + /// again as presumably the reason they started it to begin with was + /// that they no longer had access to any of their data. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{ + /// encryption::recovery, encryption::CrossSigningResetAuthType, ruma::api::client::uiaa, + /// Client, + /// }; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// # let user_id = unimplemented!(); + /// let encryption = client.encryption(); + /// + /// if let Some(handle) = encryption.recovery().reset_identity().await? { + /// match handle.auth_type() { + /// CrossSigningResetAuthType::Uiaa(uiaa) => { + /// let password = "1234".to_owned(); + /// let mut password = uiaa::Password::new(user_id, password); + /// password.session = uiaa.session; + /// + /// handle.reset(Some(uiaa::AuthData::Password(password))).await?; + /// } + /// CrossSigningResetAuthType::Oidc(o) => { + /// println!( + /// "To reset your end-to-end encryption cross-signing identity, \ + /// you first need to approve it at {}", + /// o.approval_url + /// ); + /// handle.reset(None).await?; + /// } + /// } + /// } + /// # anyhow::Ok(()) }; + /// ``` + pub async fn reset_identity(&self) -> Result> { + self.client.encryption().backups().disable().await?; // 1. + + // 2. (We can't delete account data events) + self.client.account().set_account_data(SecretStorageDisabledContent {}).await?; + self.client.encryption().recovery().update_recovery_state().await?; + + let cross_signing_reset_handle = self.client.encryption().reset_cross_signing().await?; + + if let Some(handle) = cross_signing_reset_handle { + // Authentication required, backups will be re-enabled after the reset + Ok(Some(IdentityResetHandle { + client: self.client.clone(), + cross_signing_reset_handle: handle, + })) + } else { + // No authentication required, re-enable backups + if self.client.encryption().recovery().should_auto_enable_backups().await? { + self.client.encryption().recovery().enable_backup().await?; // 4. + } + + Ok(None) + } + } + /// Recover all the secrets from the homeserver. /// /// This method is a convenience method around the @@ -567,3 +641,32 @@ impl Recovery { } } } + +/// A helper struct that handles continues resetting a user's crypto identity +/// after authentication was required and re-enabling backups (if necessary) at +/// the end of it +#[derive(Debug)] +pub struct IdentityResetHandle { + client: Client, + cross_signing_reset_handle: CrossSigningResetHandle, +} + +impl IdentityResetHandle { + /// Get the underlying [`CrossSigningResetAuthType`] this identity reset + /// process is using. + pub fn auth_type(&self) -> &CrossSigningResetAuthType { + &self.cross_signing_reset_handle.auth_type + } + + /// This method will retry to upload the device keys after the previous try + /// failed due to required authentication + pub async fn reset(&self, auth: Option) -> Result<()> { + self.cross_signing_reset_handle.auth(auth).await?; + + if self.client.encryption().recovery().should_auto_enable_backups().await? { + self.client.encryption().recovery().enable_backup().await?; + } + + Ok(()) + } +} diff --git a/crates/matrix-sdk/tests/integration/encryption/recovery.rs b/crates/matrix-sdk/tests/integration/encryption/recovery.rs index 3d713865749..3f0ca63e136 100644 --- a/crates/matrix-sdk/tests/integration/encryption/recovery.rs +++ b/crates/matrix-sdk/tests/integration/encryption/recovery.rs @@ -14,13 +14,14 @@ use std::sync::{Arc, Mutex}; +use assert_matches2::assert_let; use futures_util::StreamExt; use matrix_sdk::{ config::RequestConfig, encryption::{ backups::BackupState, recovery::{EnableProgress, RecoveryState}, - BackupDownloadStrategy, + BackupDownloadStrategy, CrossSigningResetAuthType, }, matrix_auth::{MatrixSession, MatrixSessionTokens}, test_utils::{no_retry_test_client_with_server, test_client_builder_with_server}, @@ -28,7 +29,7 @@ use matrix_sdk::{ }; use matrix_sdk_base::SessionMeta; use matrix_sdk_test::async_test; -use ruma::{device_id, user_id, UserId}; +use ruma::{api::client::uiaa, device_id, user_id, UserId}; use serde::Deserialize; use serde_json::{json, Value}; use tokio::spawn; @@ -766,3 +767,162 @@ async fn recover_and_reset() { server.verify().await } + +#[async_test] +async fn test_reset_identity() { + let user_id = user_id!("@example:morpheus.localhost"); + let (client, server) = test_client(user_id).await; + + enable(user_id, &client, &server, true).await; + + // At this point both backups and recovery should be enabled + assert_eq!(client.encryption().backups().state(), BackupState::Enabled); + assert_eq!(client.encryption().recovery().state(), RecoveryState::Enabled); + + // Disabling backups + Mock::given(method("DELETE")) + .and(path("_matrix/client/r0/room_keys/version/1")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + + // Disabling recovery + Mock::given(method("PUT")) + .and(path(format!( + "_matrix/client/r0/user/{user_id}/account_data/m.secret_storage.default_key" + ))) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .named("m.secret_storage.default_key PUT") + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path(format!( + "_matrix/client/r0/user/{user_id}/account_data/m.secret_storage.default_key" + ))) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .named("m.secret_storage.default_key account data GET") + .mount(&server) + .await; + + // Resetting cross-signing keys + let reset_handle = { + let _guard = Mock::given(method("POST")) + .and(path("/_matrix/client/unstable/keys/device_signing/upload")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "flows": [ + { + "stages": [ + "m.login.password" + ] + } + ], + "params": {}, + "session": "oFIJVvtEOCKmRUTYKTYIIPHL" + }))) + .expect(1) + .named("Initial cross-signing upload attempt") + .mount_as_scoped(&server) + .await; + + client + .encryption() + .recovery() + .reset_identity() + .await + .unwrap() + .expect("We should have received a reset handle") + }; + + Mock::given(method("POST")) + .and(path("/_matrix/client/unstable/keys/device_signing/upload")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .named("Retrying to upload the cross-signing keys") + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/_matrix/client/unstable/keys/signatures/upload")) + .respond_with(move |_: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(json!({})) + }) + .named("Final signatures upload") + .expect(1) + .mount(&server) + .await; + + // Re-enable backups + Mock::given(method("GET")) + .and(path("_matrix/client/r0/room_keys/version")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "errcode": "M_NOT_FOUND", + "error": "No current backup version" + }))) + .expect(2) + .named("room_keys/version GET") + .mount(&server) + .await; + + Mock::given(method("PUT")) + .and(path(format!( + "_matrix/client/r0/user/{user_id}/account_data/m.org.matrix.custom.backup_disabled" + ))) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .named("m.org.matrix.custom.backup_disabled PUT") + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path(format!( + "_matrix/client/r0/user/{user_id}/account_data/m.org.matrix.custom.backup_disabled" + ))) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json( + json!({"type": "m.org.matrix.custom.backup_disabled", + "content": { + "disabled": false + }}), + )) + .expect(1) + .named("m.org.matrix.custom.backup_disabled GET") + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("_matrix/client/unstable/room_keys/version")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "version": "1" }))) + .expect(1) + .named("room_keys/version POST") + .mount(&server) + .await; + + assert_let!(CrossSigningResetAuthType::Uiaa(uiaa_info) = reset_handle.auth_type()); + + let mut password = uiaa::Password::new(user_id.to_owned().into(), "1234".to_owned()); + password.session = uiaa_info.session.clone(); + reset_handle + .reset(Some(uiaa::AuthData::Password(password))) + .await + .expect("Failed retrieving identity reset handle"); + + assert!( + client.encryption().cross_signing_status().await.unwrap().is_complete(), + "After the reset we have the cross-signing available.", + ); + + // After reset backups should get renabled but recovery needs setting up again + assert_eq!(client.encryption().backups().state(), BackupState::Enabled); + assert_eq!(client.encryption().recovery().state(), RecoveryState::Disabled); + + server.verify().await; +}