use std::{collections::HashMap, path::Path};
use gettextrs::gettext;
use oo7::{Item, Keyring};
use ruma::UserId;
use thiserror::Error;
use tokio::fs;
use tracing::{debug, error, info};
use url::Url;
use super::{Secret, SecretError, StoredSession, SESSION_ID_LENGTH};
use crate::{gettext_f, prelude::*, spawn_tokio, utils::matrix, APP_ID, PROFILE};
pub const CURRENT_VERSION: u8 = 6;
pub const MIN_SUPPORTED_VERSION: u8 = 4;
mod keys {
pub(super) const XDG_SCHEMA: &str = "xdg:schema";
pub(super) const PROFILE: &str = "profile";
pub(super) const VERSION: &str = "version";
pub(super) const HOMESERVER: &str = "homeserver";
pub(super) const USER: &str = "user";
pub(super) const DEVICE_ID: &str = "device-id";
pub(super) const DB_PATH: &str = "db-path";
pub(super) const ID: &str = "id";
}
pub async fn restore_sessions() -> Result<Vec<StoredSession>, SecretError> {
match restore_sessions_inner().await {
Ok(sessions) => Ok(sessions),
Err(error) => {
error!("Could not restore previous sessions: {error}");
Err(error.into())
}
}
}
async fn restore_sessions_inner() -> Result<Vec<StoredSession>, oo7::Error> {
let keyring = Keyring::new().await?;
keyring.unlock().await?;
let items = keyring
.search_items(&HashMap::from([
(keys::XDG_SCHEMA, APP_ID),
(keys::PROFILE, PROFILE.as_str()),
]))
.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(LinuxSecretError::OldVersion {
version,
mut session,
attributes,
}) => {
if version < MIN_SUPPORTED_VERSION {
info!(
"Found old session for user {} with version {version} that is no longer supported, removing…",
session.user_id
);
log_out_session(session.clone()).await;
delete_session(&session).await;
spawn_tokio!(async move {
if let Err(error) = fs::remove_dir_all(session.data_path()).await {
error!("Could not remove session database: {error}");
}
if version >= 6 {
if let Err(error) = fs::remove_dir_all(session.cache_path()).await {
error!("Could not remove session cache: {error}");
}
}
})
.await
.unwrap();
continue;
}
info!(
"Found session {} for user {} with old version {}, applying migrations…",
session.id, session.user_id, version,
);
session.apply_migrations(version, attributes).await;
sessions.push(session);
}
Err(LinuxSecretError::Field(LinuxSecretFieldError::Invalid)) => {
}
Err(error) => {
error!("Could not restore previous session: {error}");
}
}
}
Ok(sessions)
}
pub async fn store_session(session: StoredSession) -> Result<(), SecretError> {
match store_session_inner(session).await {
Ok(()) => Ok(()),
Err(error) => {
error!("Could not store session: {error}");
Err(error.into())
}
}
}
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).unwrap();
keyring
.create_item(
&gettext_f(
"Fractal: Matrix credentials for {user_id}",
&[("user_id", session.user_id.as_str())],
),
&attributes,
secret,
true,
)
.await?;
Ok(())
}
pub async fn delete_session(session: &StoredSession) {
let attributes = session.attributes();
spawn_tokio!(async move {
if let Err(error) = delete_item_with_attributes(&attributes).await {
error!("Could not delete session data from secret backend: {error}");
}
})
.await
.unwrap();
}
async fn log_out_session(session: StoredSession) {
debug!("Logging out session");
spawn_tokio!(async move {
match matrix::client_with_stored_session(session).await {
Ok(client) => {
if let Err(error) = client.matrix_auth().logout().await {
error!("Could not log out session: {error}");
}
}
Err(error) => {
error!("Could not build client to log out session: {error}");
}
}
})
.await
.unwrap();
}
impl StoredSession {
async fn try_from_secret_item(item: Item) -> Result<Self, LinuxSecretError> {
let attributes = item.attributes().await?;
let version = parse_attribute(&attributes, keys::VERSION, str::parse::<u8>)?;
if version > CURRENT_VERSION {
return Err(LinuxSecretError::UnsupportedVersion(version));
}
let homeserver = parse_attribute(&attributes, keys::HOMESERVER, Url::parse)?;
let user_id = parse_attribute(&attributes, keys::USER, |s| UserId::parse(s))?;
let device_id = get_attribute(&attributes, keys::DEVICE_ID)?.as_str().into();
let id = if version <= 5 {
let string = get_attribute(&attributes, keys::DB_PATH)?;
Path::new(string)
.iter()
.next_back()
.and_then(|s| s.to_str())
.expect("Session ID in db-path should be valid UTF-8")
.to_owned()
} else {
get_attribute(&attributes, keys::ID)?.clone()
};
let secret = match item.secret().await {
Ok(secret) => {
if version <= 4 {
match rmp_serde::from_slice::<Secret>(&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());
}
}
}
}
Err(error) => {
error!("Could not get secret in stored session: {error}");
return Err(LinuxSecretFieldError::Invalid.into());
}
};
let session = Self {
homeserver,
user_id,
device_id,
id,
secret,
};
if version < CURRENT_VERSION {
Err(LinuxSecretError::OldVersion {
version,
session,
attributes,
})
} else {
Ok(session)
}
}
fn attributes(&self) -> HashMap<&'static str, String> {
HashMap::from([
(keys::HOMESERVER, self.homeserver.to_string()),
(keys::USER, self.user_id.to_string()),
(keys::DEVICE_ID, self.device_id.to_string()),
(keys::ID, self.id.clone()),
(keys::VERSION, CURRENT_VERSION.to_string()),
(keys::PROFILE, PROFILE.to_string()),
(keys::XDG_SCHEMA, APP_ID.to_owned()),
])
}
async fn apply_migrations(&mut self, from_version: u8, attributes: HashMap<String, String>) {
if from_version < 6 {
info!("Migrating to version 6…");
let old_path = self.data_path();
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}");
}
if let Err(error) = delete_item_with_attributes(&attributes).await {
error!("Could not remove outdated session: {error}");
}
if let Err(error) = store_session_inner(clone).await {
error!("Could not store updated session: {error}");
}
})
.await
.unwrap();
}
}
}
fn get_attribute<'a>(
attributes: &'a HashMap<String, String>,
key: &'static str,
) -> Result<&'a String, LinuxSecretFieldError> {
attributes
.get(key)
.ok_or(LinuxSecretFieldError::Missing(key))
}
fn parse_attribute<F, V, E>(
attributes: &HashMap<String, String>,
key: &'static str,
parse: F,
) -> Result<V, LinuxSecretFieldError>
where
F: FnOnce(&str) -> Result<V, E>,
E: std::fmt::Display,
{
let string = get_attribute(attributes, key)?;
match parse(string) {
Ok(value) => Ok(value),
Err(error) => {
error!("Could not parse {key} in stored session: {error}");
Err(LinuxSecretFieldError::Invalid)
}
}
}
#[derive(Debug, Error)]
pub enum LinuxSecretFieldError {
#[error("Could not find {0} in stored session")]
Missing(&'static str),
#[error("Invalid field in stored session")]
Invalid,
}
async fn delete_item_with_attributes(
attributes: &impl oo7::AsAttributes,
) -> Result<(), oo7::Error> {
let keyring = Keyring::new().await?;
keyring.delete(attributes).await?;
Ok(())
}
#[derive(Debug, Error)]
#[allow(clippy::large_enum_variant)]
pub enum LinuxSecretError {
#[error("Session found with unsupported version {0}")]
UnsupportedVersion(u8),
#[error("Session found with old version")]
OldVersion {
version: u8,
session: StoredSession,
attributes: HashMap<String, String>,
},
#[error(transparent)]
Field(#[from] LinuxSecretFieldError),
#[error(transparent)]
Oo7(#[from] oo7::Error),
}
impl From<oo7::Error> for SecretError {
fn from(value: oo7::Error) -> Self {
Self::Service(value.to_user_facing())
}
}
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 {
use oo7::portal::Error;
match self {
Error::FileHeaderMismatch(_) |
Error::VersionMismatch(_) |
Error::NoData |
Error::MacError |
Error::HashedAttributeMac(_) |
Error::GVariantDeserialization(_) |
Error::SaltSizeMismatch(_, _) |
Error::ChecksumMismatch |
Error::AlgorithmMismatch(_) |
Error::Utf8(_) => gettext(
"The secret storage file is corrupted.",
),
Error::NoParentDir(_) |
Error::NoDataDir => gettext(
"Could not access the secret storage file location.",
),
Error::Io(_) => gettext(
"An unexpected error occurred when accessing the secret storage file.",
),
Error::TargetFileChanged(_) => gettext(
"The secret storage file has been changed by another process.",
),
Error::PortalBus(_) => gettext(
"An unexpected error occurred when interacting with the D-Bus Secret Portal backend.",
),
Error::CancelledPortalRequest => gettext(
"The request to the Flatpak Secret Portal was cancelled. Make sure to accept any prompt asking to access it.",
),
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.",
),
Error::WeakKey(_) => gettext(
"The Flatpak Secret Portal provided a key that is too weak to be secure.",
),
Error::InvalidItemIndex(_) => unreachable!(),
}
}
}
impl UserFacingError for oo7::dbus::Error {
fn to_user_facing(&self) -> String {
use oo7::dbus::{Error, ServiceError};
match self {
Error::Deleted => gettext(
"The item was deleted.",
),
Error::Service(s) => match s {
ServiceError::ZBus(_) => gettext(
"An unexpected error occurred when interacting with the D-Bus Secret Service.",
),
ServiceError::IsLocked => gettext(
"The collection or item is locked.",
),
ServiceError::NoSession => gettext(
"The D-Bus Secret Service session does not exist.",
),
ServiceError::NoSuchObject => gettext(
"The collection or item does not exist.",
),
},
Error::Dismissed => gettext(
"The request to the D-Bus Secret Service was cancelled. Make sure to accept any prompt asking to access it.",
),
Error::NotFound(_) => gettext(
"Could not access the default collection. Make sure a keyring was created and set as default.",
),
Error::Zbus(_) |
Error::IO(_) => gettext(
"An unexpected error occurred when interacting with the D-Bus Secret Service.",
),
}
}
}