diff --git a/Cargo.lock b/Cargo.lock index 33c1b194..4bde5261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1325,6 +1325,7 @@ dependencies = [ "libshumate", "linkify", "matrix-sdk", + "matrix-sdk-store-encryption", "matrix-sdk-ui", "mime", "mime_guess", @@ -1337,6 +1338,7 @@ dependencies = [ "ruma", "secular", "serde", + "serde_bytes", "serde_json", "sourceview5", "strum", @@ -1349,6 +1351,7 @@ dependencies = [ "tracing-subscriber", "url", "webp", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 70e181f0..762dfbdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" rust-version = "1.82" publish = false +[package.metadata.cargo-machete] +ignored = ["serde_bytes"] # Used by the SecretFile API. + [profile.release] debug = true lto = "thin" @@ -40,6 +43,7 @@ regex = "1" rmp-serde = "1" secular = { version = "1", features = ["bmp", "normalization"] } serde = "1" +serde_bytes = "0.11" serde_json = "1" strum = { version = "0.27.1", features = ["derive"] } tempfile = "3" @@ -51,6 +55,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" webp = { version = "0.3", default-features = false } +zeroize = "1" # gtk-rs project and dependents. These usually need to be updated together. adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] } @@ -75,6 +80,11 @@ features = [ "qrcode", ] +[dependencies.matrix-sdk-store-encryption] +# version = "0.10" +git = "https://github.com/matrix-org/matrix-rust-sdk.git" +rev = "6c57003d172fdf2bd5595075701cf0d2e85b32e4" + [dependencies.matrix-sdk-ui] # version = "0.10" git = "https://github.com/matrix-org/matrix-rust-sdk.git" diff --git a/src/login/mod.rs b/src/login/mod.rs index 42c4f316..4e3c4e13 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -399,7 +399,7 @@ mod imp { // Client. let homeserver = client.homeserver(); - match Session::new(homeserver, (&response).into()).await { + match Session::create(homeserver, (&response).into()).await { Ok(session) => { self.init_session(session).await; } diff --git a/src/secret/file.rs b/src/secret/file.rs new file mode 100644 index 00000000..04896db6 --- /dev/null +++ b/src/secret/file.rs @@ -0,0 +1,92 @@ +//! API to store the session tokens in a file on the system. + +use std::path::Path; + +use matrix_sdk_store_encryption::{EncryptedValue, StoreCipher}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tokio::fs; + +/// An API to read from or write to a file that encodes its content. +pub(super) struct SecretFile; + +impl SecretFile { + /// Read a secret from the file at the given path. + pub(super) async fn read( + path: &Path, + passphrase: &str, + ) -> Result { + let (cipher, encrypted_secret) = Self::read_inner(path, passphrase).await?; + let serialized_secret = cipher.decrypt_value_data(encrypted_secret)?; + Ok(rmp_serde::from_slice(&serialized_secret)?) + } + + async fn read_inner( + path: &Path, + passphrase: &str, + ) -> Result<(StoreCipher, EncryptedValue), SecretFileError> { + let bytes = fs::read(&path).await?; + let content = rmp_serde::from_slice::(&bytes)?; + let cipher = StoreCipher::import(passphrase, &content.encrypted_cipher)?; + Ok((cipher, content.encrypted_secret)) + } + + /// Get the existing cipher at the given path, or create a new one. + async fn get_or_create_cipher( + path: &Path, + passphrase: &str, + ) -> Result { + let cipher = match Self::read_inner(path, passphrase).await { + Ok((cipher, _)) => cipher, + Err(_) => StoreCipher::new()?, + }; + Ok(cipher) + } + + /// Write a secret to the file at the given path. + pub(super) async fn write( + path: &Path, + passphrase: &str, + secret: &T, + ) -> Result<(), SecretFileError> { + let cipher = Self::get_or_create_cipher(path, passphrase).await?; + // `StoreCipher::encrypt_value()` uses JSON to serialize the data, which shows + // in the content of the file. To have a more opaque format, we use + // `rmp_serde::to_vec()` which will not show the fields of + // `EncryptedValue`. + let encrypted_secret = cipher.encrypt_value_data(rmp_serde::to_vec_named(secret)?)?; + let encrypted_cipher = cipher.export(passphrase)?; + let bytes = rmp_serde::to_vec(&SecretFileContent { + encrypted_cipher, + encrypted_secret, + })?; + fs::write(path, bytes).await?; + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct SecretFileContent { + #[serde(with = "serde_bytes")] + encrypted_cipher: Vec, + encrypted_secret: EncryptedValue, +} + +/// All errors that can occur when interacting with a secret file. +#[derive(Debug, thiserror::Error)] +pub(super) enum SecretFileError { + /// An error occurred when accessing the file. + #[error(transparent)] + File(#[from] std::io::Error), + + /// An error occurred when decoding the content. + #[error(transparent)] + Decode(#[from] rmp_serde::decode::Error), + + /// An error occurred when encoding the content. + #[error(transparent)] + Encode(#[from] rmp_serde::encode::Error), + + /// An error occurred when encrypting or decrypting the content. + #[error(transparent)] + Encryption(#[from] matrix_sdk_store_encryption::Error), +} diff --git a/src/secret/linux.rs b/src/secret/linux.rs index ef7e3198..63be54de 100644 --- a/src/secret/linux.rs +++ b/src/secret/linux.rs @@ -6,16 +6,17 @@ use std::{collections::HashMap, path::Path}; use gettextrs::gettext; use oo7::{Item, Keyring}; use ruma::UserId; +use serde::Deserialize; use thiserror::Error; use tokio::fs; use tracing::{debug, error, info}; use url::Url; -use super::{SecretData, SecretError, SecretExt, StoredSession, SESSION_ID_LENGTH}; +use super::{SecretError, SecretExt, SessionTokens, StoredSession, SESSION_ID_LENGTH}; use crate::{gettext_f, prelude::*, spawn_tokio, utils::matrix, APP_ID, PROFILE}; /// The current version of the stored session. -const CURRENT_VERSION: u8 = 6; +const CURRENT_VERSION: u8 = 7; /// The minimum supported version for the stored sessions. /// /// Currently, this matches the version when Fractal 5 was released. @@ -102,7 +103,8 @@ async fn restore_sessions_inner() -> Result, oo7::Error> { Err(LinuxSecretError::OldVersion { version, mut session, - attributes, + item, + access_token, }) => { if version < MIN_SUPPORTED_VERSION { info!( @@ -111,7 +113,9 @@ async fn restore_sessions_inner() -> Result, oo7::Error> { ); // Try to log it out. - log_out_session(session.clone()).await; + if let Some(access_token) = access_token { + log_out_session(session.clone(), access_token).await; + } // Delete the session from the secret backend. LinuxSecret::delete_session(&session).await; @@ -138,7 +142,7 @@ async fn restore_sessions_inner() -> Result, oo7::Error> { "Found session {} for user {} with old version {}, applying migrations…", session.id, session.user_id, version, ); - session.apply_migrations(version, attributes).await; + session.apply_migrations(version, item, access_token).await; sessions.push(session); } @@ -158,7 +162,7 @@ async fn store_session_inner(session: StoredSession) -> Result<(), oo7::Error> { let keyring = Keyring::new().await?; let attributes = session.attributes(); - let secret = serde_json::to_string(&session.secret).expect("serializing secret should succeed"); + let secret = oo7::Secret::text(session.passphrase); keyring .create_item( @@ -178,10 +182,16 @@ async fn store_session_inner(session: StoredSession) -> Result<(), oo7::Error> { } /// Create a client and log out the given session. -async fn log_out_session(session: StoredSession) { +async fn log_out_session(session: StoredSession, access_token: String) { debug!("Logging out session"); + + let tokens = SessionTokens { + access_token, + refresh_token: None, + }; + spawn_tokio!(async move { - match matrix::client_with_stored_session(session).await { + match matrix::client_with_stored_session(session, tokens).await { Ok(client) => { if let Err(error) = client.matrix_auth().logout().await { error!("Could not log out session: {error}"); @@ -197,7 +207,7 @@ async fn log_out_session(session: StoredSession) { } impl StoredSession { - /// Build self from a secret. + /// Build self from an item. async fn try_from_secret_item(item: Item) -> Result { let attributes = item.attributes().await?; @@ -220,21 +230,35 @@ impl StoredSession { } else { get_attribute(&attributes, keys::ID)?.clone() }; - let secret = match item.secret().await { + let (passphrase, access_token) = match item.secret().await { Ok(secret) => { - if version <= 4 { - match rmp_serde::from_slice::(&secret) { - Ok(secret) => secret, - Err(error) => { - error!("Could not parse secret in stored session: {error}"); - return Err(LinuxSecretFieldError::Invalid.into()); + if version <= 6 { + let secret_data = if version <= 4 { + match rmp_serde::from_slice::(&secret) { + Ok(secret) => secret, + Err(error) => { + error!("Could not parse secret in stored session: {error}"); + return Err(LinuxSecretFieldError::Invalid.into()); + } } - } + } else { + match serde_json::from_slice(&secret) { + Ok(secret) => secret, + Err(error) => { + error!("Could not parse secret in stored session: {error:?}"); + return Err(LinuxSecretFieldError::Invalid.into()); + } + } + }; + + (secret_data.passphrase, Some(secret_data.access_token)) } else { - match serde_json::from_slice(&secret) { - Ok(secret) => secret, + // Even if we store the secret as plain text, the file backend always returns a + // blob so let's always treat it as a byte slice. + match String::from_utf8(secret.as_bytes().to_owned()) { + Ok(passphrase) => (passphrase.clone(), None), Err(error) => { - error!("Could not parse secret in stored session: {error:?}"); + error!("Could not get secret in stored session: {error}"); return Err(LinuxSecretFieldError::Invalid.into()); } } @@ -251,14 +275,15 @@ impl StoredSession { user_id, device_id, id, - secret, + passphrase: passphrase.into(), }; if version < CURRENT_VERSION { Err(LinuxSecretError::OldVersion { version, session, - attributes, + item, + access_token, }) } else { Ok(session) @@ -279,48 +304,84 @@ impl StoredSession { } /// Migrate this session to the current version. - async fn apply_migrations(&mut self, from_version: u8, attributes: HashMap) { + async fn apply_migrations( + &mut self, + from_version: u8, + item: Item, + access_token: Option, + ) { + // Version 5 changes the serialization of the secret from MessagePack to JSON. + // We can ignore the migration because we changed the format of the secret again + // in version 7. + if from_version < 6 { - // Version 5 changes the serialization of the secret from MessagePack to JSON. // Version 6 truncates sessions IDs, changing the path of the databases, and // removes the `db-path` attribute to replace it with the `id` attribute. - // They both remove and add again the item in the secret backend so we merged - // the migrations. + // Because we need to update the `version` in the attributes for version 7, we + // only migrate the path here. info!("Migrating to version 6…"); - // Keep the old state of the session. + // Get the old path of the session. let old_path = self.data_path(); // Truncate the session ID. self.id.truncate(SESSION_ID_LENGTH); let new_path = self.data_path(); - let clone = self.clone(); spawn_tokio!(async move { debug!( "Renaming databases directory to: {}", new_path.to_string_lossy() ); + if let Err(error) = fs::rename(old_path, new_path).await { error!("Could not rename databases directory: {error}"); } + }) + .await + .expect("task was not aborted"); + } + + if from_version < 7 { + // Version 7 moves the access token to a separate file. Only the passphrase is + // stored as the secret now. + info!("Migrating to version 7…"); - // Changing an attribute in an item creates a new item in oo7 because of a bug, - // so we need to delete it and create a new one. - if let Err(error) = delete_item_with_attributes(&attributes).await { - error!("Could not remove outdated session: {error}"); + let new_attributes = self.attributes(); + let new_secret = oo7::Secret::text(&self.passphrase); + + spawn_tokio!(async move { + if let Err(error) = item.set_secret(new_secret).await { + error!("Could not store updated session secret: {error}"); } - if let Err(error) = store_session_inner(clone).await { - error!("Could not store updated session: {error}"); + if let Err(error) = item.set_attributes(&new_attributes).await { + error!("Could not store updated session attributes: {error}"); } }) .await .expect("task was not aborted"); + + if let Some(access_token) = access_token { + let session_tokens = SessionTokens { + access_token, + refresh_token: None, + }; + self.store_tokens(session_tokens).await; + } } } } +/// Secret data that was stored in the secret backend from versions 4 through 6. +#[derive(Clone, Deserialize)] +struct V4SecretData { + /// The access token to provide to the homeserver for authentication. + access_token: String, + /// The passphrase used to encrypt the local databases. + passphrase: String, +} + /// Get the attribute with the given key in the given map. fn get_attribute<'a>( attributes: &'a HashMap, @@ -398,12 +459,12 @@ enum LinuxSecretError { version: u8, /// The session that was found. session: StoredSession, - /// The attributes of the secret item for the session. + /// The item for the session. + item: Item, + /// The access token that was found. /// - /// We use it to update the secret item because, if we use the `Item` - /// directly, the Secret portal API returns errors saying that the file - /// has changed after the first item was modified. - attributes: HashMap, + /// It needs to be stored outside of the secret backend now. + access_token: Option, }, /// An error occurred while retrieving a field of the session. diff --git a/src/secret/mod.rs b/src/secret/mod.rs index dd5dc086..cd2e0ea8 100644 --- a/src/secret/mod.rs +++ b/src/secret/mod.rs @@ -3,10 +3,7 @@ use std::{fmt, path::PathBuf}; use gtk::glib; -use matrix_sdk::{ - authentication::matrix::{MatrixSession, MatrixSessionTokens}, - SessionMeta, -}; +use matrix_sdk::{authentication::matrix::MatrixSessionTokens, SessionMeta}; use rand::{ distr::{Alphanumeric, SampleString}, rng, @@ -17,10 +14,13 @@ use thiserror::Error; use tokio::fs; use tracing::{debug, error}; use url::Url; +use zeroize::Zeroizing; +mod file; #[cfg(target_os = "linux")] mod linux; +use self::file::SecretFile; use crate::{ prelude::*, spawn_tokio, @@ -106,8 +106,8 @@ pub struct StoredSession { /// /// This is the name of the directories where the session data lives. pub id: String, - /// The secrets of the session. - pub secret: SecretData, + /// The passphrase used to encrypt the local databases. + pub passphrase: Zeroizing, } impl fmt::Debug for StoredSession { @@ -122,18 +122,16 @@ impl fmt::Debug for StoredSession { } impl StoredSession { - /// Construct a `StoredSession` from the given login data. + /// Construct a `StoredSession` from the given SDK user session data. /// /// Returns an error if we failed to generate a unique session ID for the /// new session. - pub(crate) fn with_login_data( + pub(crate) async fn new( homeserver: Url, - data: MatrixSession, + meta: SessionMeta, + tokens: SessionTokens, ) -> Result { - let MatrixSession { - meta: SessionMeta { user_id, device_id }, - tokens: MatrixSessionTokens { access_token, .. }, - } = data; + let SessionMeta { user_id, device_id } = meta; // Generate a unique random session ID. let mut id = None; @@ -157,18 +155,17 @@ impl StoredSession { let passphrase = Alphanumeric.sample_string(&mut rng(), PASSPHRASE_LENGTH); - let secret = SecretData { - access_token, - passphrase, - }; - - Ok(Self { + let session = Self { homeserver, user_id, device_id, id, - secret, - }) + passphrase: passphrase.into(), + }; + + session.store_tokens(tokens).await; + + Ok(session) } /// The path where the persistent data of this session lives. @@ -205,13 +202,76 @@ impl StoredSession { .await .expect("task was not aborted"); } + + /// The path to the files containing the session tokens. + fn tokens_path(&self) -> PathBuf { + let mut path = self.data_path(); + path.push("tokens"); + path + } + + /// Load the tokens of this session. + pub(crate) async fn load_tokens(&self) -> Option { + let tokens_path = self.tokens_path(); + let passphrase = self.passphrase.clone(); + + let handle = spawn_tokio!(async move { SecretFile::read(&tokens_path, &passphrase).await }); + + match handle.await.expect("task was not aborted") { + Ok(tokens) => Some(tokens), + Err(error) => { + error!("Could not load session tokens: {error}"); + None + } + } + } + + /// Store the tokens of this session. + pub(crate) async fn store_tokens(&self, tokens: SessionTokens) { + let tokens_path = self.tokens_path(); + let passphrase = self.passphrase.clone(); + + let handle = + spawn_tokio!( + async move { SecretFile::write(&tokens_path, &passphrase, &tokens).await } + ); + + if let Err(error) = handle.await.expect("task was not aborted") { + error!("Could not store session tokens: {error}"); + } + } } -/// Secret data that can be stored in the secret backend. -#[derive(Clone, Deserialize, Serialize)] -pub struct SecretData { - /// The access token to provide to the homeserver for authentication. - pub access_token: String, - /// The passphrase used to encrypt the local databases. - pub passphrase: String, +/// The tokens of a user session. +#[derive(Serialize, Deserialize)] +pub(crate) struct SessionTokens { + access_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, +} + +impl From for SessionTokens { + fn from(value: MatrixSessionTokens) -> Self { + let MatrixSessionTokens { + access_token, + refresh_token, + } = value; + SessionTokens { + access_token, + refresh_token, + } + } +} + +impl From for MatrixSessionTokens { + fn from(value: SessionTokens) -> Self { + let SessionTokens { + access_token, + refresh_token, + } = value; + MatrixSessionTokens { + access_token, + refresh_token, + } + } } diff --git a/src/session/model/session.rs b/src/session/model/session.rs index e81c2044..c2a767ab 100644 --- a/src/session/model/session.rs +++ b/src/session/model/session.rs @@ -579,25 +579,19 @@ glib::wrapper! { } impl Session { - /// Create a new session. - pub async fn new(homeserver: Url, data: MatrixSession) -> Result { - let stored_session = StoredSession::with_login_data(homeserver, data)?; - let settings = Application::default() - .session_list() - .settings() - .get_or_create(&stored_session.id); - - Self::restore(stored_session, settings).await - } - - /// Restore a stored session. - pub(crate) async fn restore( + /// Construct an existing session. + pub(crate) async fn new( stored_session: StoredSession, settings: SessionSettings, ) -> Result { + let tokens = stored_session + .load_tokens() + .await + .ok_or(ClientSetupError::NoSessionTokens)?; + let stored_session_clone = stored_session.clone(); let client = spawn_tokio!(async move { - let client = matrix::client_with_stored_session(stored_session_clone).await?; + let client = matrix::client_with_stored_session(stored_session_clone, tokens).await?; // Make sure that we use the proper retention policy. let media = client.media(); @@ -616,12 +610,26 @@ impl Session { .expect("task was not aborted")?; Ok(glib::Object::builder() - .property("info", &stored_session) + .property("info", stored_session) .property("settings", settings) .property("client", BoxedClient(client)) .build()) } + /// Create a new session after login. + pub(crate) async fn create( + homeserver: Url, + data: MatrixSession, + ) -> Result { + let stored_session = StoredSession::new(homeserver, data.meta, data.tokens.into()).await?; + let settings = Application::default() + .session_list() + .settings() + .get_or_create(&stored_session.id); + + Self::new(stored_session, settings).await + } + /// Finish initialization of this session. pub(crate) async fn prepare(&self) { self.imp().prepare().await; diff --git a/src/session_list/mod.rs b/src/session_list/mod.rs index d6587e21..10e29afa 100644 --- a/src/session_list/mod.rs +++ b/src/session_list/mod.rs @@ -271,7 +271,7 @@ mod imp { /// Restore a stored session. async fn restore_stored_session(&self, session_info: &StoredSession) { let settings = self.settings.get_or_create(&session_info.id); - match Session::restore(session_info.clone(), settings).await { + match Session::new(session_info.clone(), settings).await { Ok(session) => { session.prepare().await; self.insert(session); diff --git a/src/utils/matrix/mod.rs b/src/utils/matrix/mod.rs index 02807a23..8c36472a 100644 --- a/src/utils/matrix/mod.rs +++ b/src/utils/matrix/mod.rs @@ -5,7 +5,7 @@ use std::{borrow::Cow, str::FromStr}; use gettextrs::gettext; use gtk::{glib, prelude::*}; use matrix_sdk::{ - authentication::matrix::{MatrixSession, MatrixSessionTokens}, + authentication::matrix::MatrixSession, config::RequestConfig, deserialized_responses::RawAnySyncOrStrippedTimelineEvent, encryption::{BackupDownloadStrategy, EncryptionSettings}, @@ -37,7 +37,7 @@ use crate::{ components::Pill, gettext_f, prelude::*, - secret::{SecretData, StoredSession}, + secret::{SessionTokens, StoredSession}, session::model::{RemoteRoom, Room}, }; @@ -267,6 +267,9 @@ pub enum ClientSetupError { /// An error creating the unique local ID of the session. #[error("Could not generate unique session ID")] NoSessionId, + /// An error accessing the session tokens. + #[error("Could not access session tokens")] + NoSessionTokens, } impl UserFacingError for ClientSetupError { @@ -275,6 +278,7 @@ impl UserFacingError for ClientSetupError { Self::Client(err) => err.to_user_facing(), Self::Sdk(err) => err.to_user_facing(), Self::NoSessionId => gettext("Could not generate unique session ID"), + Self::NoSessionTokens => gettext("Could not access the session tokens"), } } } @@ -282,6 +286,7 @@ impl UserFacingError for ClientSetupError { /// Create a [`Client`] with the given stored session. pub async fn client_with_stored_session( session: StoredSession, + tokens: SessionTokens, ) -> Result { let data_path = session.data_path(); let cache_path = session.cache_path(); @@ -290,19 +295,13 @@ pub async fn client_with_stored_session( homeserver, user_id, device_id, - id: _, - secret: SecretData { - access_token, - passphrase, - }, + passphrase, + .. } = session; let session_data = MatrixSession { meta: SessionMeta { user_id, device_id }, - tokens: MatrixSessionTokens { - access_token, - refresh_token: None, - }, + tokens: tokens.into(), }; let encryption_settings = EncryptionSettings {