From 97026fc3a699e13a13dedca45d200af45a86066e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Sat, 11 Nov 2023 18:02:37 +0100 Subject: [PATCH] Recovery support Co-authored-by: Jonas Platte --- crates/matrix-sdk/src/client/mod.rs | 9 +- .../matrix-sdk/src/encryption/backups/mod.rs | 1 + crates/matrix-sdk/src/encryption/mod.rs | 14 + .../src/encryption/recovery/futures.rs | 252 +++++++++ .../matrix-sdk/src/encryption/recovery/mod.rs | 522 ++++++++++++++++++ .../src/encryption/recovery/types.rs | 126 +++++ .../integration/encryption/secret_storage.rs | 6 +- 7 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 crates/matrix-sdk/src/encryption/recovery/futures.rs create mode 100644 crates/matrix-sdk/src/encryption/recovery/mod.rs create mode 100644 crates/matrix-sdk/src/encryption/recovery/types.rs diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 9ebb11987..5bba84822 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -85,8 +85,9 @@ use crate::{ }; #[cfg(feature = "e2e-encryption")] use crate::{ - encryption::backups::types::BackupClientState, - encryption::{Encryption, EncryptionSettings}, + encryption::{ + backups::types::BackupClientState, recovery::RecoveryState, Encryption, EncryptionSettings, + }, store_locks::CrossProcessStoreLock, }; @@ -251,6 +252,8 @@ pub(crate) struct ClientInner { pub(crate) encryption_settings: EncryptionSettings, #[cfg(feature = "e2e-encryption")] pub(crate) backup_state: BackupClientState, + #[cfg(feature = "e2e-encryption")] + pub(crate) recovery_state: SharedObservable, } impl ClientInner { @@ -291,6 +294,8 @@ impl ClientInner { encryption_settings, #[cfg(feature = "e2e-encryption")] backup_state: Default::default(), + #[cfg(feature = "e2e-encryption")] + recovery_state: Default::default(), }; #[allow(clippy::let_and_return)] diff --git a/crates/matrix-sdk/src/encryption/backups/mod.rs b/crates/matrix-sdk/src/encryption/backups/mod.rs index 0653907f1..a8b5d5377 100644 --- a/crates/matrix-sdk/src/encryption/backups/mod.rs +++ b/crates/matrix-sdk/src/encryption/backups/mod.rs @@ -862,6 +862,7 @@ impl Backups { /// removed on the homeserver. async fn handle_deleted_backup_version(&self, olm_machine: &OlmMachine) -> Result<(), Error> { olm_machine.backup_machine().disable_backup().await?; + self.client.encryption().recovery().update_state_after_backup_disabling().await; self.set_state(BackupState::Unknown); Ok(()) diff --git a/crates/matrix-sdk/src/encryption/mod.rs b/crates/matrix-sdk/src/encryption/mod.rs index 377990054..508d21637 100644 --- a/crates/matrix-sdk/src/encryption/mod.rs +++ b/crates/matrix-sdk/src/encryption/mod.rs @@ -62,6 +62,7 @@ use self::{ backups::Backups, futures::PrepareEncryptedFile, identities::{DeviceUpdates, IdentityUpdates}, + recovery::Recovery, secret_storage::SecretStorage, }; use crate::{ @@ -78,6 +79,7 @@ use crate::{ pub mod backups; pub mod futures; pub mod identities; +pub mod recovery; pub mod secret_storage; pub mod verification; @@ -114,6 +116,9 @@ pub struct EncryptionSettings { /// /// [`SecretStore::import_secrets()`]: crate::encryption::secret_storage::SecretStore::import_secrets pub auto_download_from_backup: bool, + + /// Automatically create a backup version if no backup exists. + pub auto_enable_backups: bool, } impl Client { @@ -152,6 +157,7 @@ impl Client { let response = self.send(request, None).await?; self.mark_request_as_sent(request_id, &response).await?; + self.encryption().recovery().update_state_after_keys_query(&response).await; Ok(response) } @@ -1086,6 +1092,11 @@ impl Encryption { Backups { client: self.client.to_owned() } } + /// Get the recovery manager of the client. + pub fn recovery(&self) -> Recovery { + Recovery { 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 @@ -1212,6 +1223,9 @@ impl Encryption { if let Err(e) = this.backups().setup_and_resume().await { error!("Couldn't setup and resume backups {e:?}"); } + if let Err(e) = this.recovery().setup().await { + error!("Couldn't setup and resume recovery {e:?}"); + } })); Ok(()) diff --git a/crates/matrix-sdk/src/encryption/recovery/futures.rs b/crates/matrix-sdk/src/encryption/recovery/futures.rs new file mode 100644 index 000000000..61acd9182 --- /dev/null +++ b/crates/matrix-sdk/src/encryption/recovery/futures.rs @@ -0,0 +1,252 @@ +// Copyright 2023 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. + +//! Named futures for the recovery support. + +use std::future::IntoFuture; + +use futures_core::Stream; +use futures_util::{pin_mut, StreamExt}; +use matrix_sdk_common::boxed_into_future; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tracing::{warn, Instrument, Span}; + +use super::{EnableProgress, Recovery, RecoveryError, Result}; +use crate::{ + encryption::{backups::UploadState, secret_storage::SecretStore}, + utils::ChannelObservable, +}; + +/// Named future for the [`Recovery::enable()`] method. +#[derive(Debug)] +pub struct Enable<'a> { + pub(super) recovery: &'a Recovery, + pub(super) progress: ChannelObservable, + pub(super) wait_for_backups_upload: bool, + pub(super) passphrase: Option<&'a str>, + tracing_span: Span, +} + +impl<'a> Enable<'a> { + pub(super) fn new(recovery: &'a Recovery) -> Self { + Self { + recovery, + progress: Default::default(), + wait_for_backups_upload: false, + passphrase: None, + tracing_span: Span::current(), + } + } + + /// Subscribe to updates to the recovery enabling progress. + pub fn subscribe_to_progress( + &self, + ) -> impl Stream> { + self.progress.subscribe() + } + + /// Should the enabling of the recovery also wait for *all* room keys to be + /// uploaded to the server-side key backup? + /// + /// This is useful if the user is enabling recovery and the room key backup + /// just before logging out. Otherwise the logout might finish before + /// all room keys have been backed up and thus historic messages will + /// fail to decrypt once the user logs back in again. + pub fn wait_for_backups_to_upload(mut self) -> Self { + self.wait_for_backups_upload = true; + + self + } + + /// In addition to the recovery key the [`Recovery::enable()`] method + /// returns, allow this passphrase to be used for the + /// [`Recovery::recover()`] method. + pub fn with_passphrase(mut self, passphrase: &'a str) -> Self { + self.passphrase = Some(passphrase); + + self + } +} + +impl<'a> IntoFuture for Enable<'a> { + type Output = Result; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + let Self { recovery, progress, wait_for_backups_upload, passphrase, tracing_span } = self; + + let future = async move { + if !recovery.client.encryption().backups().are_enabled().await { + if recovery.client.encryption().backups().exists_on_server().await? { + return Err(RecoveryError::BackupExistsOnServer); + } else { + progress.set(EnableProgress::CreatingBackup); + recovery.mark_backup_as_enabled().await?; + recovery.client.encryption().backups().create().await?; + } + } + + progress.set(EnableProgress::CreatingRecoveryKey); + + let secret_storage = recovery.client.encryption().secret_storage(); + + let create_store = if let Some(passphrase) = passphrase { + secret_storage.create_secret_store().with_passphrase(passphrase) + } else { + secret_storage.create_secret_store() + }; + + let store: SecretStore = create_store.await?; + + if wait_for_backups_upload { + let backups = recovery.client.encryption().backups(); + let upload_future = backups.wait_for_steady_state(); + let upload_progress = upload_future.subscribe_to_progress(); + + #[allow(unused_variables)] + let progress_task = matrix_sdk_common::executor::spawn({ + let progress = progress.clone(); + async move { + pin_mut!(upload_progress); + + while let Some(update) = upload_progress.next().await { + match update { + Ok(UploadState::Uploading(count)) => { + progress.set(EnableProgress::BackingUp(count)); + } + Ok(UploadState::Done | UploadState::Error) | Err(_) => break, + _ => (), + } + } + } + }); + + if let Err(e) = upload_future.await { + warn!("Couldn't upload all the room keys to the backup: {e:?}"); + progress.set(EnableProgress::RoomKeyUploadError); + } + + #[cfg(not(target_arch = "wasm32"))] + progress_task.abort(); + } else { + recovery.client.encryption().backups().maybe_trigger_backup(); + } + + let key = store.secret_storage_key(); + + progress.set(EnableProgress::Done { recovery_key: key }); + recovery.update_recovery_state().await?; + + Ok(store.secret_storage_key()) + }; + + Box::pin(future.instrument(tracing_span)) + } +} + +/// Named future for the [`Recovery::reset_key()`] method. +#[derive(Debug)] +pub struct Reset<'a> { + pub(super) recovery: &'a Recovery, + pub(super) passphrase: Option<&'a str>, + tracing_span: Span, +} + +impl<'a> Reset<'a> { + pub(super) fn new(recovery: &'a Recovery) -> Self { + Self { recovery, passphrase: None, tracing_span: Span::current() } + } + + /// In addition to the recovery key the [`Recovery::reset_key()`] method + /// returns, allow this passphrase to be used for the + /// [`Recovery::recover()`] method. + pub fn with_passphrase(mut self, passphrase: &'a str) -> Self { + self.passphrase = Some(passphrase); + + self + } +} + +impl<'a> IntoFuture for Reset<'a> { + type Output = Result; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + let Self { recovery, passphrase, tracing_span } = self; + + let future = async move { + let secret_storage = recovery.client.encryption().secret_storage(); + + let create_store = if let Some(passphrase) = passphrase { + secret_storage.create_secret_store().with_passphrase(passphrase) + } else { + secret_storage.create_secret_store() + }; + + let store: SecretStore = create_store.await?; + recovery.update_recovery_state().await?; + + Ok(store.secret_storage_key()) + }; + + Box::pin(future.instrument(tracing_span)) + } +} + +/// Named future for the [`Recovery::recover_and_reset()`] method. +#[derive(Debug)] +pub struct RecoverAndReset<'a> { + pub(super) recovery: &'a Recovery, + pub(super) old_recovery_key: &'a str, + pub(super) passphrase: Option<&'a str>, + tracing_span: Span, +} + +impl<'a> RecoverAndReset<'a> { + pub(super) fn new(recovery: &'a Recovery, old_recovery_key: &'a str) -> Self { + Self { recovery, old_recovery_key, passphrase: None, tracing_span: Span::current() } + } + + /// In addition to the new recovery key the + /// [`Recovery::recover_and_reset()`] method returns, allow this + /// passphrase to be used for the [`Recovery::recover()`] method. + pub fn with_passphrase(mut self, passphrase: &'a str) -> Self { + self.passphrase = Some(passphrase); + + self + } +} + +impl<'a> IntoFuture for RecoverAndReset<'a> { + type Output = Result; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + let Self { recovery, old_recovery_key, passphrase, tracing_span } = self; + + let future = async move { + recovery.recover(old_recovery_key).await?; + + let reset = if let Some(passphrase) = passphrase { + recovery.reset_key().with_passphrase(passphrase) + } else { + recovery.reset_key() + }; + + reset.await + }; + + Box::pin(future.instrument(tracing_span)) + } +} diff --git a/crates/matrix-sdk/src/encryption/recovery/mod.rs b/crates/matrix-sdk/src/encryption/recovery/mod.rs new file mode 100644 index 000000000..43fd3600d --- /dev/null +++ b/crates/matrix-sdk/src/encryption/recovery/mod.rs @@ -0,0 +1,522 @@ +// Copyright 2023 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. + +//! The recovery module +//! +//! The recovery module attempts to provide a unified and simplified view over +//! the secret storage and backup subsystems. +//! +//! **Note**: If you are using this module, do not use the [`SecretStorage`] and +//! [`Backups`] subsystems directly. This module makes assumptions that might be +//! broken by the direct usage of the respective lower level modules. +//! +//! **Note**: The term Recovery used in this submodule is not the same as the +//! [`Recovery key`] mentioned in the spec. The recovery key from the spec is +//! solely about backups, while the term recovery in this file includes both the +//! backups and the secret storage subsystems. The recovery key mentioned in +//! this file is the secret storage key. +//! +//! You should configure your client to bootstrap cross-signing automatically +//! and may chose to let your client automatically create a backup, if it +//! doesn't exist, as well: +//! +//! ```no_run +//! use matrix_sdk::{encryption::EncryptionSettings, Client}; +//! +//! # async { +//! # let homeserver = "http://example.org"; +//! let client = Client::builder() +//! .homeserver_url(homeserver) +//! .with_encryption_settings(EncryptionSettings { +//! auto_enable_cross_signing: true, +//! auto_enable_backups: true, +//! ..Default::default() +//! }) +//! .build() +//! .await?; +//! # anyhow::Ok(()) }; +//! ``` +//! +//! # Examples +//! +//! For a newly registered user you will want to enable recovery, either +//! immediately or before the user logs out. +//! +//! ```no_run +//! # use matrix_sdk::{Client, encryption::recovery::EnableProgress}; +//! # use url::Url; +//! # async { +//! # let homeserver = Url::parse("http://example.com")?; +//! # let client = Client::new(homeserver).await?; +//! let recovery = client.encryption().recovery(); +//! +//! // Create a new recovery key, you can use the provided passphrase, or the returned recovery key +//! // to recover. +//! let recovery_key = recovery +//! .enable() +//! .wait_for_backups_to_upload() +//! .with_passphrase("my passphrase") +//! .await; +//! # anyhow::Ok(()) }; +//! ``` +//! +//! If the user logs in with another device, you'll want to let the user recover +//! its secrets by entering the recovery key or recovery passphrase. +//! +//! ```no_run +//! # use matrix_sdk::{Client, encryption::recovery::EnableProgress}; +//! # use url::Url; +//! # async { +//! # let homeserver = Url::parse("http://example.com")?; +//! # let client = Client::new(homeserver).await?; +//! let recovery = client.encryption().recovery(); +//! +//! // Create a new recovery key, you can use the provided passphrase, or the returned recovery key +//! // to recover. +//! recovery.recover("my recovery key or passphrase").await; +//! # anyhow::Ok(()) }; +//! ``` +//! +//! [`Recovery key`]: https://spec.matrix.org/v1.8/client-server-api/#recovery-key + +use futures_core::Stream; +use ruma::{ + api::client::keys::get_keys, + events::{ + secret::send::ToDeviceSecretSendEvent, + secret_storage::default_key::SecretStorageDefaultKeyEvent, + }, +}; +use tracing::{error, info, instrument}; + +#[cfg(doc)] +use crate::encryption::{ + backups::Backups, + secret_storage::{SecretStorage, SecretStore}, +}; +use crate::Client; + +pub mod futures; +mod types; +pub use self::types::{EnableProgress, RecoveryError, RecoveryState, Result}; +use self::{ + futures::{Enable, RecoverAndReset, Reset}, + types::{BackupDisabledContent, SecretStorageDisabledContent}, +}; + +/// The recovery manager for the [`Client`]. +#[derive(Debug)] +pub struct Recovery { + pub(super) client: Client, +} + +impl Recovery { + /// Get the current [`RecoveryState`] for this [`Client`]. + pub fn state(&self) -> RecoveryState { + self.client.inner.recovery_state.get() + } + + /// Get a stream of updates to the [`RecoveryState`]. + /// + /// This method will send out the current state as the first update. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::RecoveryState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// use futures_util::StreamExt; + /// + /// let recovery = client.encryption().recovery(); + /// + /// let mut state_stream = recovery.state_stream(); + /// + /// while let Some(update) = state_stream.next().await { + /// match update { + /// RecoveryState::Enabled => { + /// println!("Recovery has been enabled"); + /// } + /// _ => (), + /// } + /// } + /// # anyhow::Ok(()) }; + /// ``` + pub fn state_stream(&self) -> impl Stream { + self.client.inner.recovery_state.subscribe_reset() + } + + /// Enable secret storage *and* backups. + /// + /// This method will create a new secret storage key and a new backup if one + /// doesn't already exist. It will then upload all the locally cached + /// secrets, including the backup recovery key, to the new secret store. + /// + /// This method will throw an error if a backup already exists on the + /// homeserver but this [`Client`] isn't connected to the existing backup. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::EnableProgress}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// use futures_util::StreamExt; + /// + /// let recovery = client.encryption().recovery(); + /// + /// let enable = recovery + /// .enable() + /// .wait_for_backups_to_upload() + /// .with_passphrase("my passphrase"); + /// + /// let mut progress_stream = enable.subscribe_to_progress(); + /// + /// tokio::spawn(async move { + /// while let Some(update) = progress_stream.next().await { + /// let Ok(update) = update else { + /// panic!("Update to the enable progress lagged") + /// }; + /// + /// match update { + /// EnableProgress::CreatingBackup => { + /// println!("Creating a new backup"); + /// } + /// EnableProgress::CreatingRecoveryKey => { + /// println!("Creating a new recovery key"); + /// } + /// EnableProgress::Done { .. } => { + /// println!("Recovery has been enabled"); + /// break; + /// } + /// _ => (), + /// } + /// } + /// }); + /// + /// let recovery_key = enable.await?; + /// + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub fn enable(&self) -> Enable<'_> { + Enable::new(self) + } + + /// Create a new backup if one does not exist yet. + /// + /// This method will throw an error if a backup already exists on the + /// homeserver but this [`Client`] isn't connected to the existing backup. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::backups::BackupState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// let recovery = client.encryption().recovery(); + /// + /// recovery.enable_backup().await?; + /// + /// assert_eq!(client.encryption().backups().state(), BackupState::Enabled); + /// + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub async fn enable_backup(&self) -> Result<()> { + if !self.client.encryption().backups().exists_on_server().await? { + self.mark_backup_as_enabled().await?; + + self.client.encryption().backups().create().await?; + self.client.encryption().backups().maybe_trigger_backup(); + + Ok(()) + } else { + Err(RecoveryError::BackupExistsOnServer) + } + } + + /// Disable recovery completely. + /// + /// This method will do the following steps: + /// + /// 1. Disable the uploading of room keys to a currently active backup. + /// 2. Delete the currently active backup. + /// 3. Set the `m.secret_storage.default_key` global account data event to + /// an empty JSON content. + /// 4. Set a global account data event so clients won't attempt to + /// automatically re-enable a backup. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::RecoveryState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// let recovery = client.encryption().recovery(); + /// + /// recovery.disable().await?; + /// + /// assert_eq!(recovery.state(), RecoveryState::Disabled); + /// + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub async fn disable(&self) -> Result<()> { + self.client.encryption().backups().disable().await?; + // Why oh why, can't we delete account data events? + self.client.account().set_account_data(SecretStorageDisabledContent {}).await?; + self.client.account().set_account_data(BackupDisabledContent { disabled: true }).await?; + self.update_recovery_state().await?; + // TODO: Do we want to "delete" the known secrets as well? + + Ok(()) + } + + /// Reset the recovery key. + /// + /// This will rotate the secret storage key and re-upload all the secrets to + /// the [`SecretStore`]. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::RecoveryState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// let recovery = client.encryption().recovery(); + /// + /// let new_recovery_key = + /// recovery.reset_key().with_passphrase("my passphrase").await; + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub fn reset_key(&self) -> Reset<'_> { + // TODO: Should this only be possible if we're in the RecoveryState::Enabled + // state? Otherwise we'll create a new secret store but won't be able to + // upload all the secrets. + Reset::new(self) + } + + /// Reset the recovery key but first import all the secrets from secret + /// storage. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::RecoveryState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// let recovery = client.encryption().recovery(); + /// + /// let new_recovery_key = recovery + /// .recover_and_reset("my old passphrase or key") + /// .with_passphrase("my new passphrase") + /// .await?; + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub fn recover_and_reset<'a>(&'a self, old_key: &'a str) -> RecoverAndReset<'_> { + RecoverAndReset::new(self, old_key) + } + + /// Recover all the secrets from the homeserver. + /// + /// This method is a convenience method around the + /// [`SecretStore::import_secrets()`] method, please read the documentation + /// of this method for more information about what happens if you call + /// this method. + /// + /// In short, this method will turn a newly created [`Client`] into a fully + /// end-to-end encryption enabled client. + /// + /// # Examples + /// + /// ```no_run + /// # use matrix_sdk::{Client, encryption::recovery::RecoveryState}; + /// # use url::Url; + /// # async { + /// # let homeserver = Url::parse("http://example.com")?; + /// # let client = Client::new(homeserver).await?; + /// let recovery = client.encryption().recovery(); + /// + /// recovery.recover("my recovery key or passphrase").await; + /// + /// assert_eq!(recovery.state(), RecoveryState::Enabled); + /// # anyhow::Ok(()) }; + /// ``` + #[instrument(skip_all)] + pub async fn recover(&self, recovery_key: &str) -> Result<()> { + let store = + self.client.encryption().secret_storage().open_secret_store(recovery_key).await?; + + store.import_secrets().await?; + self.update_recovery_state().await?; + + Ok(()) + } + + /// Is this device the last device the user has? + /// + /// This method is useful to check if we should recommend to the user that + /// they should enable recovery, typically done before logging out. + /// + /// If the user does not enable recovery before logging out of their last + /// device, they will not be able to decrypt historic messages once they + /// create a new device. + pub async fn are_we_the_last_man_standing(&self) -> Result { + let olm_machine = self.client.olm_machine().await; + let olm_machine = olm_machine.as_ref().ok_or(crate::Error::NoOlmMachine)?; + let user_id = olm_machine.user_id(); + + self.client.encryption().ensure_initial_key_query().await?; + + let devices = self.client.encryption().get_user_devices(user_id).await?; + + Ok(devices.devices().count() == 1) + } + + async fn all_known_secrets_available(&self) -> Result { + let cross_signing_complete = self + .client + .encryption() + .cross_signing_status() + .await + .map(|status| status.is_complete()) + .unwrap_or_default(); + + // The backup state is fine if we have backups enabled locally, or if backups + // have been marked as disabled. + let backup_state_ok = if self.client.encryption().backups().are_enabled().await { + true + } else { + self.are_backups_marked_as_disabled().await? + }; + + Ok(cross_signing_complete && backup_state_ok) + } + + async fn should_auto_enable_backups(&self) -> Result { + // If we didn't already enable backups, we don't see a backup version on the + // server, and finally if backups have not been marked to be explicitly + // disabled, then we can automatically enable them. + Ok(self.client.inner.encryption_settings.auto_enable_backups + && !self.client.encryption().backups().are_enabled().await + && !self.client.encryption().backups().exists_on_server().await? + && !self.are_backups_marked_as_disabled().await?) + } + + pub(crate) async fn setup(&self) -> Result<()> { + info!("Setting up account data listeners and trying to setup recovery"); + + self.update_recovery_state().await?; + + if self.should_auto_enable_backups().await? { + self.enable_backup().await?; + } + + self.client.add_event_handler(Self::default_key_event_handler); + self.client.add_event_handler(Self::secret_send_event_handler); + + Ok(()) + } + + async fn are_backups_marked_as_disabled(&self) -> Result { + Ok(self + .client + .account() + .fetch_account_data(BackupDisabledContent::event_type()) + .await? + .map(|event| { + event + .deserialize_as::() + .map(|event| event.disabled) + .unwrap_or(false) + }) + .unwrap_or(false)) + } + + async fn mark_backup_as_enabled(&self) -> Result<()> { + self.client.account().set_account_data(BackupDisabledContent { disabled: false }).await?; + + Ok(()) + } + + async fn check_recovery_state(&self) -> Result { + Ok(if self.client.encryption().secret_storage().is_enabled().await? { + if self.all_known_secrets_available().await? { + RecoveryState::Enabled + } else { + RecoveryState::Incomplete + } + } else { + RecoveryState::Disabled + }) + } + + async fn update_recovery_state(&self) -> Result<()> { + let new_state = self.check_recovery_state().await?; + self.client.inner.recovery_state.set(new_state); + + Ok(()) + } + + async fn update_recovery_state_no_fail(&self) { + if let Err(e) = self.update_recovery_state().await { + error!("Coulnd't update the recovery state: {e:?}"); + } + } + + #[instrument] + async fn secret_send_event_handler(_: ToDeviceSecretSendEvent, client: Client) { + client.encryption().recovery().update_recovery_state_no_fail().await; + } + + #[instrument] + async fn default_key_event_handler(_: SecretStorageDefaultKeyEvent, client: Client) { + client.encryption().recovery().update_recovery_state_no_fail().await; + } + + #[instrument] + pub(crate) async fn update_state_after_backup_disabling(&self) { + // TODO: This is quite ugly, this method is called by the backups subsystem. + // Backups shouldn't depend on recovery, recovery should listen to the + // backup state change. + self.update_recovery_state_no_fail().await; + } + + #[instrument] + pub(crate) async fn update_state_after_keys_query(&self, response: &get_keys::v3::Response) { + if let Some(user_id) = self.client.user_id() { + if response.master_keys.contains_key(user_id) { + // TODO: This is unnecessarily expensive, we could let the crypto crate notify + // us that our private keys got erased... But, the OlmMachine + // gets recreated and... You know the drill by now... + self.update_recovery_state_no_fail().await; + } + } + } +} diff --git a/crates/matrix-sdk/src/encryption/recovery/types.rs b/crates/matrix-sdk/src/encryption/recovery/types.rs new file mode 100644 index 000000000..0f04d2fc1 --- /dev/null +++ b/crates/matrix-sdk/src/encryption/recovery/types.rs @@ -0,0 +1,126 @@ +// Copyright 2023 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. + +use matrix_sdk_base::crypto::store::RoomKeyCounts; +use ruma::{ + events::{EventContent, GlobalAccountDataEventType}, + exports::ruma_macros::EventContent, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +#[cfg(doc)] +use crate::encryption::{ + backups::Backups, + recovery::{futures::Enable, Recovery}, +}; + +/// Result type alias for the [`Recovery`] subsystem. +pub type Result = std::result::Result; + +/// Error type for the [`Recovery`] subsystem. +#[derive(Debug, Error)] +pub enum RecoveryError { + /// A backup already exists on the homeserver, the recovery subsystem does + /// not allow backups to be overwritten, disable recovery first. + #[error( + "A backup already exists on the homeserver and the method does not allow to overwrite it" + )] + BackupExistsOnServer, + + /// A typical SDK error. + #[error(transparent)] + Sdk(#[from] crate::Error), + + /// Error in the secret storage subsystem. + #[error(transparent)] + SecretStorage(#[from] crate::encryption::secret_storage::SecretStorageError), +} + +/// Enum describing the states the [`Recovery::enable()`] method can be in. +#[derive(Debug, Default, Clone, Zeroize, ZeroizeOnDrop)] +pub enum EnableProgress { + /// The client is just starting the process of enabling recovery, this is + /// the initial state. + #[default] + Starting, + /// The client is creating a new server-side key backup. + CreatingBackup, + /// The client is creating a new recovery key and uploading all the locally + /// cached secrets to the homeserver. + CreatingRecoveryKey, + /// The client is currently backing up room keys to the server-side key + /// backup. This state may be emitted multiple times until all room keys + /// have been backed up. + #[zeroize(skip)] + BackingUp(RoomKeyCounts), + /// The client encountered an error while trying to upload all room keys to + /// the server-side key backup. + /// + /// Not all room keys may have been backed up, the client will try to back + /// them up again at a later point. If you'd like to wait for the backup + /// to finish again you can use the [`Backups::wait_for_steady_state()`] + /// method. + RoomKeyUploadError, + /// Recovery has been successfully enabled, this is the final state. + Done { + /// The newly created recovery key. + // TODO: Can I remove this from here? It seems a bit dumb. + recovery_key: String, + }, +} + +/// The states the recovery subsystem can be in. +/// +/// You can listen on the state of the recovery mechanism using the +/// [`Recovery::state_stream()`] method. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RecoveryState { + /// We didn't yet inform ourselves about the state of things. + #[default] + Unknown, + /// Secret storage is setup and we have all the secrets locally. + Enabled, + /// No default secret storage key exists or it is disabled explicitly using + /// the account data event. + Disabled, + /// Secret storage is setup but we're missing some secrets. + Incomplete, +} + +/// A hack to allow the `m.secret_storage.default_key` event to be "deleted". +/// +/// This allows us to set the `m.secret_storage.default_key` event to an empty +/// JSON object, which means that the event will be invalid. +#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)] +#[ruma_event(type = "m.secret_storage.default_key", kind = GlobalAccountData)] +pub(super) struct SecretStorageDisabledContent {} + +/// A custom global account data event which tells us that a new backup should +/// not be automatically created. +#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)] +#[ruma_event(type = "m.org.matrix.custom.backup_disabled", kind = GlobalAccountData)] +pub(super) struct BackupDisabledContent { + pub disabled: bool, +} + +impl BackupDisabledContent { + /// Get the event type of the [`BackupDisabledContent`] global account data + /// event. + pub(super) fn event_type() -> GlobalAccountDataEventType { + // This is dumb, there's got to be a better way to get to the event type? + Self { disabled: false }.event_type() + } +} diff --git a/crates/matrix-sdk/tests/integration/encryption/secret_storage.rs b/crates/matrix-sdk/tests/integration/encryption/secret_storage.rs index bc3880c20..3bcb2944a 100644 --- a/crates/matrix-sdk/tests/integration/encryption/secret_storage.rs +++ b/crates/matrix-sdk/tests/integration/encryption/secret_storage.rs @@ -153,7 +153,7 @@ async fn secret_store_missing_key_info() { .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "key": key_id }))) - .expect(1) + .expect(1..) .named("default_key account data GET") .mount(&server) .await; @@ -203,7 +203,7 @@ async fn secret_store_not_setup() { "errcode": "M_NOT_FOUND", "error": "Account data not found" }))) - .expect(1) + .expect(1..) .named("default_key account data GET") .mount(&server) .await; @@ -589,7 +589,7 @@ async fn is_secret_storage_enabled() { "errcode": "M_NOT_FOUND", "error": "Account data not found" }))) - .expect(1) + .expect(1..) .named("default_key account data GET") .mount_as_scoped(&server) .await;