Browse Source

secret: Store tokens in a separate file

When we switch to supporting OAuth 2.0, the tokens will need to be
refreshed often. To avoid issues where the secret backend might stop
responding, we store them encrypted in a separate file. The secret
backend now only stores the passphrase.
pipelines/816197
Kévin Commaille 1 year ago
parent
commit
8a6b71e496
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 3
      Cargo.lock
  2. 10
      Cargo.toml
  3. 2
      src/login/mod.rs
  4. 92
      src/secret/file.rs
  5. 139
      src/secret/linux.rs
  6. 116
      src/secret/mod.rs
  7. 38
      src/session/model/session.rs
  8. 2
      src/session_list/mod.rs
  9. 21
      src/utils/matrix/mod.rs

3
Cargo.lock generated

@ -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]]

10
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"

2
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;
}

92
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<T: DeserializeOwned>(
path: &Path,
passphrase: &str,
) -> Result<T, SecretFileError> {
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::<SecretFileContent>(&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<StoreCipher, SecretFileError> {
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<T: Serialize>(
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<u8>,
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),
}

139
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<Vec<StoredSession>, 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<Vec<StoredSession>, 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<Vec<StoredSession>, 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<Self, LinuxSecretError> {
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::<SecretData>(&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::<V4SecretData>(&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<String, String>) {
async fn apply_migrations(
&mut self,
from_version: u8,
item: Item,
access_token: Option<String>,
) {
// 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<String, String>,
@ -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<String, String>,
/// It needs to be stored outside of the secret backend now.
access_token: Option<String>,
},
/// An error occurred while retrieving a field of the session.

116
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<String>,
}
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<Self, ClientSetupError> {
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<SessionTokens> {
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<String>,
}
impl From<MatrixSessionTokens> for SessionTokens {
fn from(value: MatrixSessionTokens) -> Self {
let MatrixSessionTokens {
access_token,
refresh_token,
} = value;
SessionTokens {
access_token,
refresh_token,
}
}
}
impl From<SessionTokens> for MatrixSessionTokens {
fn from(value: SessionTokens) -> Self {
let SessionTokens {
access_token,
refresh_token,
} = value;
MatrixSessionTokens {
access_token,
refresh_token,
}
}
}

38
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<Self, ClientSetupError> {
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<Self, ClientSetupError> {
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<Self, ClientSetupError> {
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;

2
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);

21
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<Client, ClientSetupError> {
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 {

Loading…
Cancel
Save