fractal/src/secret.rs
2023-10-20 13:29:26 +00:00

570 lines
19 KiB
Rust

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<PathBuf> = 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<String, String>,
},
/// 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<Item>) {
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<Self, SecretError> {
let attr = item.attributes().await?;
let version = match attr.get("version") {
Some(string) => match string.parse::<u8>() {
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>(&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<Item>, 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<FromUtf8Error> for FromUtf8SecretError {
fn from(err: FromUtf8Error) -> Self {
Self::Str(err)
}
}
impl From<JsonError> 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<Self, FromUtf8SecretError> {
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<Vec<StoredSession>, 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)
}