use std::{collections::HashMap, ffi::OsStr, fmt, fs, path::PathBuf, string::FromUtf8Error}; use gettextrs::gettext; use gtk::glib; use matrix_sdk::{ matrix_auth::{MatrixSession, MatrixSessionTokens}, SessionMeta, }; use once_cell::sync::Lazy; use oo7::{Item, Keyring}; use rand::{distributions::Alphanumeric, thread_rng, Rng}; use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId}; use serde::{Deserialize, Serialize}; use serde_json::error::Error as JsonError; use thiserror::Error; use tracing::{debug, error, warn}; use url::Url; use crate::{gettext_f, prelude::*, spawn_tokio, utils::matrix, APP_ID, PROFILE}; pub const CURRENT_VERSION: u8 = 3; const SCHEMA_ATTRIBUTE: &str = "xdg:schema"; static DATA_PATH: Lazy = Lazy::new(|| glib::user_data_dir().join(PROFILE.as_str())); /// Any error that can happen when interacting with the secret service. #[derive(Debug, Error)] pub enum SecretError { /// A session with an unsupported version was found. #[error("Session found with unsupported version {version}")] UnsupportedVersion { version: u8, item: Item, attributes: HashMap, }, /// A session with an old version was found. #[error("Session found with old version")] OldVersion { item: Item, session: StoredSession }, /// A corrupted session was found. #[error("{error}")] CorruptSession { error: String, item: Item }, /// An error occurred interacting with the secret service. #[error(transparent)] Oo7(#[from] oo7::Error), /// Trying to restore a session with the wrong profile. #[error("Session found for wrong profile")] WrongProfile, } impl SecretError { /// Split `self` between its message and its optional `Item`. pub fn into_parts(self) -> (String, Option) { match self { SecretError::UnsupportedVersion { version, item, .. } => ( gettext_f( // Translators: Do NOT translate the content between '{' and '}', this is a // variable name. "Found stored session with unsupported version {version_nb}", &[("version_nb", &version.to_string())], ), Some(item), ), SecretError::CorruptSession { error, item } => (error, Some(item)), SecretError::Oo7(error) => (error.to_user_facing(), None), error => (error.to_string(), None), } } } impl UserFacingError for oo7::Error { fn to_user_facing(self) -> String { match self { oo7::Error::Portal(error) => error.to_user_facing(), oo7::Error::DBus(error) => error.to_user_facing(), } } } impl UserFacingError for oo7::portal::Error { fn to_user_facing(self) -> String { match self { oo7::portal::Error::FileHeaderMismatch(_) | oo7::portal::Error::VersionMismatch(_) | oo7::portal::Error::NoData | oo7::portal::Error::MacError | oo7::portal::Error::HashedAttributeMac(_) | oo7::portal::Error::GVariantDeserialization(_) | oo7::portal::Error::SaltSizeMismatch(_, _) => gettext( "The secret storage file is corrupted.", ), oo7::portal::Error::NoParentDir(_) | oo7::portal::Error::NoDataDir => gettext( "Could not access the secret storage file location.", ), oo7::portal::Error::Io(_) => gettext( "An unknown error occurred when accessing the secret storage file.", ), oo7::portal::Error::TargetFileChanged(_) => gettext( "The secret storage file has been changed by another process.", ), oo7::portal::Error::PortalBus(_) => gettext( "An unknown error occurred when interacting with the D-Bus Secret Portal backend.", ), oo7::portal::Error::CancelledPortalRequest => gettext( "The request to the Flatpak Secret Portal was cancelled. Make sure to accept any prompt asking to access it.", ), oo7::portal::Error::PortalNotAvailable => gettext( "The Flatpak Secret Portal is not available. Make sure xdg-desktop-portal is installed, and it is at least at version 1.5.0.", ), oo7::portal::Error::WeakKey(_) => gettext( "The Flatpak Secret Portal provided a key that is too weak to be secure.", ), // Can only occur when using the `replace_item_index` or `delete_item_index` methods. oo7::portal::Error::InvalidItemIndex(_) => unreachable!(), } } } impl UserFacingError for oo7::dbus::Error { fn to_user_facing(self) -> String { match self { oo7::dbus::Error::Deleted => gettext( "The item was deleted.", ), oo7::dbus::Error::Dismissed => gettext( "The request to the D-Bus Secret Service was cancelled. Make sure to accept any prompt asking to access it.", ), oo7::dbus::Error::NotFound(_) => gettext( "Could not access the default collection. Make sure a keyring was created and set as default.", ), oo7::dbus::Error::Zbus(_) | oo7::dbus::Error::IO(_) => gettext( "An unknown error occurred when interacting with the D-Bus Secret Service.", ), } } } #[derive(Clone)] pub struct StoredSession { pub homeserver: Url, pub user_id: OwnedUserId, pub device_id: OwnedDeviceId, pub path: PathBuf, pub secret: Secret, pub version: u8, } impl fmt::Debug for StoredSession { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("StoredSession") .field("homeserver", &self.homeserver) .field("user_id", &self.user_id) .field("device_id", &self.device_id) .field("path", &self.path) .field("version", &self.version) .finish() } } impl StoredSession { /// Build self from a secret. pub async fn try_from_secret_item(item: Item) -> Result { let attr = item.attributes().await?; let version = match attr.get("version") { Some(string) => match string.parse::() { Ok(version) => version, Err(error) => { error!("Could not parse 'version' attribute in stored session: {error}"); return Err(SecretError::CorruptSession { error: gettext("Malformed version in stored session"), item, }); } }, None => 0, }; if version > CURRENT_VERSION { return Err(SecretError::UnsupportedVersion { version, item, attributes: attr, }); } // TODO: Remove this and request profile in Keyring::search_items when we remove // migration. match attr.get("profile") { // Ignore the item if it's for another profile. Some(profile) if *profile != PROFILE.as_str() => return Err(SecretError::WrongProfile), // It's an error if the version is at least 2 but there is no profile. // Versions older than 2 will be migrated. None if version >= 2 => { return Err(SecretError::CorruptSession { error: gettext("Could not find profile in stored session"), item, }); } // No issue for other cases. _ => {} }; let homeserver = match attr.get("homeserver") { Some(string) => match Url::parse(string) { Ok(homeserver) => homeserver, Err(error) => { error!("Could not parse 'homeserver' attribute in stored session: {error}"); return Err(SecretError::CorruptSession { error: gettext("Malformed homeserver in stored session"), item, }); } }, None => { return Err(SecretError::CorruptSession { error: gettext("Could not find homeserver in stored session"), item, }); } }; let user_id = match attr.get("user") { Some(string) => match UserId::parse(string.as_str()) { Ok(user_id) => user_id, Err(error) => { error!("Could not parse 'user' attribute in stored session: {error}"); return Err(SecretError::CorruptSession { error: gettext("Malformed user ID in stored session"), item, }); } }, None => { return Err(SecretError::CorruptSession { error: gettext("Could not find user ID in stored session"), item, }); } }; let device_id = match attr.get("device-id") { Some(string) => <&DeviceId>::from(string.as_str()).to_owned(), None => { return Err(SecretError::CorruptSession { error: gettext("Could not find device ID in stored session"), item, }); } }; let path = match attr.get("db-path") { Some(string) => PathBuf::from(string), None => { return Err(SecretError::CorruptSession { error: gettext("Could not find database path in stored session"), item, }); } }; let secret = match item.secret().await { Ok(secret) => { if version == 0 { match Secret::from_utf8(&secret) { Ok(secret) => secret, Err(error) => { error!("Could not parse secret in stored session: {error:?}"); return Err(SecretError::CorruptSession { error: gettext("Malformed secret in stored session"), item, }); } } } else { match rmp_serde::from_slice::(&secret) { Ok(secret) => secret, Err(error) => { error!("Could not parse secret in stored session: {error}"); return Err(SecretError::CorruptSession { error: gettext("Malformed secret in stored session"), item, }); } } } } Err(error) => { error!("Could not get secret in stored session: {error}"); return Err(SecretError::CorruptSession { error: gettext("Could not get secret in stored session"), item, }); } }; let session = Self { homeserver, user_id, device_id, path, secret, version, }; if version < CURRENT_VERSION { Err(SecretError::OldVersion { item, session }) } else { Ok(session) } } /// Construct a `StoredSession` from the given login data. pub fn with_login_data(homeserver: Url, data: MatrixSession) -> Self { let MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: MatrixSessionTokens { access_token, .. }, } = data; let path = DATA_PATH.join(glib::uuid_string_random().as_str()); let passphrase = thread_rng() .sample_iter(Alphanumeric) .take(30) .map(char::from) .collect(); let secret = Secret { access_token, passphrase, }; Self { homeserver, user_id, device_id, path, secret, version: CURRENT_VERSION, } } /// Split this `StoredSession` into parts. pub fn into_parts(self) -> (Url, PathBuf, String, MatrixSession) { let Self { homeserver, user_id, device_id, path, secret: Secret { access_token, passphrase, }, .. } = self; let data = MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: MatrixSessionTokens { access_token, refresh_token: None, }, }; (homeserver, path, passphrase, data) } /// Get the attributes from `self`. pub fn attributes(&self) -> HashMap<&str, String> { HashMap::from([ ("homeserver", self.homeserver.to_string()), ("user", self.user_id.to_string()), ("device-id", self.device_id.to_string()), ("db-path", self.path.to_str().unwrap().to_owned()), ("version", self.version.to_string()), ("profile", PROFILE.to_string()), (SCHEMA_ATTRIBUTE, APP_ID.to_owned()), ]) } /// Get the unique ID for this `StoredSession`. /// /// This is the name of the folder where the DB is stored. pub fn id(&self) -> &str { self.path .iter() .next_back() .and_then(OsStr::to_str) .unwrap() } /// Write this session to the `SecretService`, overwriting any previously /// stored session with the same attributes. pub async fn store(&self) -> Result<(), SecretError> { let keyring = Keyring::new().await?; let attrs = self.attributes(); let attributes = attrs.iter().map(|(k, v)| (*k, v.as_ref())).collect(); let secret = rmp_serde::to_vec_named(&self.secret).unwrap(); keyring .create_item( &gettext_f( // Translators: Do NOT translate the content between '{' and '}', this is a // variable name. "Fractal: Matrix credentials for {user_id}", &[("user_id", self.user_id.as_str())], ), attributes, secret, true, ) .await?; Ok(()) } /// Delete this session from the system. pub async fn delete(self, item: Option, logout: bool) { debug!( "Removing stored session {} with version {} for Matrix user {}…", self.id(), self.version, self.user_id, ); spawn_tokio!(async move { if logout { debug!("Logging out session"); match matrix::client_with_stored_session(self.clone()).await { Ok(client) => { if let Err(error) = client.matrix_auth().logout().await { error!("Failed to log out session: {error}"); } } Err(error) => { error!("Failed to build client to log out session: {error}") } } } if let Some(item) = item { if let Err(error) = item.delete().await { error!("Failed to delete session item from Secret Service: {error}"); }; } else if let Err(error) = self.delete_from_secret_service().await { error!("Failed to delete session data from Secret Service: {error}"); } if let Err(error) = fs::remove_dir_all(self.path) { error!("Failed to remove session database: {error}"); } }) .await .unwrap(); } /// Remove this session from the `SecretService` async fn delete_from_secret_service(&self) -> Result<(), SecretError> { let keyring = Keyring::new().await?; let attrs = self.attributes(); let attributes = attrs.iter().map(|(k, v)| (*k, v.as_ref())).collect(); keyring.delete(attributes).await?; Ok(()) } /// Migrate this session to version 3. /// /// This implies moving the database under the profile's directory. pub async fn migrate_to_v3(mut self, item: Item) { warn!( "Session {} with version {} found for user {}, migrating to version 3…", self.id(), self.version, self.user_id, ); let target_path = DATA_PATH.join(self.id()); if self.path != target_path { debug!("Moving database to: {}", target_path.to_string_lossy()); if let Err(error) = fs::create_dir_all(&target_path) { error!("Failed to create new directory: {error}"); } if let Err(error) = fs::rename(&self.path, &target_path) { error!("Failed to move database: {error}"); } self.path = target_path; } self.version = 3; spawn_tokio!(async move { if let Err(error) = item.delete().await { error!("Failed to remove outdated session: {error}"); } if let Err(error) = self.store().await { error!("Failed to store updated session: {error}"); } }) .await .unwrap(); } } /// A possible error value when converting a `Secret` from a UTF-8 byte vector. #[derive(Debug)] pub enum FromUtf8SecretError { Str(FromUtf8Error), Json(JsonError), } impl From for FromUtf8SecretError { fn from(err: FromUtf8Error) -> Self { Self::Str(err) } } impl From for FromUtf8SecretError { fn from(err: JsonError) -> Self { Self::Json(err) } } /// A `Secret` that can be stored in the `SecretService`. #[derive(Clone, Deserialize, Serialize)] pub struct Secret { pub access_token: String, pub passphrase: String, } impl Secret { /// Converts a vector of bytes to a `Secret`. pub fn from_utf8(slice: &[u8]) -> Result { let s = String::from_utf8(slice.to_owned())?; Ok(serde_json::from_str(&s)?) } } /// Retrieves all sessions stored to the `SecretService` pub async fn restore_sessions() -> Result, SecretError> { let keyring = Keyring::new().await?; keyring.unlock().await?; let items = keyring .search_items(HashMap::from([(SCHEMA_ATTRIBUTE, APP_ID)])) .await?; let mut sessions = Vec::with_capacity(items.len()); for item in items { item.unlock().await?; match StoredSession::try_from_secret_item(item).await { Ok(session) => sessions.push(session), Err(SecretError::WrongProfile) => {} Err(error) => return Err(error), } } Ok(sessions) }