1use std::{collections::HashMap, path::Path};
5
6use gettextrs::gettext;
7use matrix_sdk::authentication::oauth::ClientId;
8use oo7::{Item, Keyring};
9use ruma::UserId;
10use serde::Deserialize;
11use thiserror::Error;
12use tokio::fs;
13use tracing::{debug, error, info};
14use url::Url;
15
16use super::{SecretError, SecretExt, SessionTokens, StoredSession, SESSION_ID_LENGTH};
17use crate::{gettext_f, prelude::*, spawn_tokio, utils::matrix, APP_ID, PROFILE};
18
19const CURRENT_VERSION: u8 = 7;
21const MIN_SUPPORTED_VERSION: u8 = 4;
25
26mod keys {
28 pub(super) const XDG_SCHEMA: &str = "xdg:schema";
30 pub(super) const PROFILE: &str = "profile";
32 pub(super) const VERSION: &str = "version";
34 pub(super) const HOMESERVER: &str = "homeserver";
36 pub(super) const USER: &str = "user";
38 pub(super) const DEVICE_ID: &str = "device-id";
40 pub(super) const DB_PATH: &str = "db-path";
42 pub(super) const ID: &str = "id";
44 pub(super) const CLIENT_ID: &str = "client-id";
46}
47
48pub(crate) struct LinuxSecret;
50
51impl SecretExt for LinuxSecret {
52 async fn restore_sessions() -> Result<Vec<StoredSession>, SecretError> {
53 let handle = spawn_tokio!(async move { restore_sessions_inner().await });
54 match handle.await.expect("task was not aborted") {
55 Ok(sessions) => Ok(sessions),
56 Err(error) => {
57 error!("Could not restore previous sessions: {error}");
58 Err(error.into())
59 }
60 }
61 }
62
63 async fn store_session(session: StoredSession) -> Result<(), SecretError> {
64 let handle = spawn_tokio!(async move { store_session_inner(session).await });
65 match handle.await.expect("task was not aborted") {
66 Ok(()) => Ok(()),
67 Err(error) => {
68 error!("Could not store session: {error}");
69 Err(error.into())
70 }
71 }
72 }
73
74 async fn delete_session(session: &StoredSession) {
75 let attributes = session.attributes();
76
77 spawn_tokio!(async move {
78 if let Err(error) = delete_item_with_attributes(&attributes).await {
79 error!("Could not delete session data from secret backend: {error}");
80 }
81 })
82 .await
83 .expect("task was not aborted");
84 }
85}
86
87async fn restore_sessions_inner() -> Result<Vec<StoredSession>, oo7::Error> {
88 let keyring = Keyring::new().await?;
89
90 keyring.unlock().await?;
91
92 let items = keyring
93 .search_items(&HashMap::from([
94 (keys::XDG_SCHEMA, APP_ID),
95 (keys::PROFILE, PROFILE.as_str()),
96 ]))
97 .await?;
98
99 let mut sessions = Vec::with_capacity(items.len());
100
101 for item in items {
102 item.unlock().await?;
103
104 match StoredSession::try_from_secret_item(item).await {
105 Ok(session) => sessions.push(session),
106 Err(LinuxSecretError::OldVersion {
107 version,
108 mut session,
109 item,
110 access_token,
111 }) => {
112 if version < MIN_SUPPORTED_VERSION {
113 info!(
114 "Found old session for user {} with version {version} that is no longer supported, removing…",
115 session.user_id
116 );
117
118 if let Some(access_token) = access_token {
120 log_out_session(session.clone(), access_token).await;
121 }
122
123 LinuxSecret::delete_session(&session).await;
125
126 spawn_tokio!(async move {
128 if let Err(error) = fs::remove_dir_all(session.data_path()).await {
129 error!("Could not remove session database: {error}");
130 }
131
132 if version >= 6 {
133 if let Err(error) = fs::remove_dir_all(session.cache_path()).await {
134 error!("Could not remove session cache: {error}");
135 }
136 }
137 })
138 .await
139 .expect("task was not aborted");
140
141 continue;
142 }
143
144 info!(
145 "Found session {} for user {} with old version {}, applying migrations…",
146 session.id, session.user_id, version,
147 );
148 session.apply_migrations(version, item, access_token).await;
149
150 sessions.push(session);
151 }
152 Err(LinuxSecretError::Field(LinuxSecretFieldError::Invalid)) => {
153 }
155 Err(error) => {
156 error!("Could not restore previous session: {error}");
157 }
158 }
159 }
160
161 Ok(sessions)
162}
163
164async fn store_session_inner(session: StoredSession) -> Result<(), oo7::Error> {
165 let keyring = Keyring::new().await?;
166
167 let attributes = session.attributes();
168 let secret = oo7::Secret::text(session.passphrase);
169
170 keyring
171 .create_item(
172 &gettext_f(
173 "Fractal: Matrix credentials for {user_id}",
176 &[("user_id", session.user_id.as_str())],
177 ),
178 &attributes,
179 secret,
180 true,
181 )
182 .await?;
183
184 Ok(())
185}
186
187async fn log_out_session(session: StoredSession, access_token: String) {
189 debug!("Logging out session");
190
191 let tokens = SessionTokens {
192 access_token,
193 refresh_token: None,
194 };
195
196 spawn_tokio!(async move {
197 match matrix::client_with_stored_session(session, tokens).await {
198 Ok(client) => {
199 if let Err(error) = client.logout().await {
200 error!("Could not log out session: {error}");
201 }
202 }
203 Err(error) => {
204 error!("Could not build client to log out session: {error}");
205 }
206 }
207 })
208 .await
209 .expect("task was not aborted");
210}
211
212impl StoredSession {
213 async fn try_from_secret_item(item: Item) -> Result<Self, LinuxSecretError> {
215 let attributes = item.attributes().await?;
216
217 let version = parse_attribute(&attributes, keys::VERSION, str::parse::<u8>)?;
218 if version > CURRENT_VERSION {
219 return Err(LinuxSecretError::UnsupportedVersion(version));
220 }
221
222 let homeserver = parse_attribute(&attributes, keys::HOMESERVER, Url::parse)?;
223 let user_id = parse_attribute(&attributes, keys::USER, |s| UserId::parse(s))?;
224 let device_id = get_attribute(&attributes, keys::DEVICE_ID)?.as_str().into();
225
226 let id = if version <= 5 {
227 let string = get_attribute(&attributes, keys::DB_PATH)?;
228 Path::new(string)
229 .iter()
230 .next_back()
231 .and_then(|s| s.to_str())
232 .expect("Session ID in db-path should be valid UTF-8")
233 .to_owned()
234 } else {
235 get_attribute(&attributes, keys::ID)?.clone()
236 };
237
238 let client_id = attributes.get(keys::CLIENT_ID).cloned().map(ClientId::new);
239
240 let (passphrase, access_token) = match item.secret().await {
241 Ok(secret) => {
242 if version <= 6 {
243 let secret_data = if version <= 4 {
244 match rmp_serde::from_slice::<V4SecretData>(&secret) {
245 Ok(secret) => secret,
246 Err(error) => {
247 error!("Could not parse secret in stored session: {error}");
248 return Err(LinuxSecretFieldError::Invalid.into());
249 }
250 }
251 } else {
252 match serde_json::from_slice(&secret) {
253 Ok(secret) => secret,
254 Err(error) => {
255 error!("Could not parse secret in stored session: {error:?}");
256 return Err(LinuxSecretFieldError::Invalid.into());
257 }
258 }
259 };
260
261 (secret_data.passphrase, Some(secret_data.access_token))
262 } else {
263 match String::from_utf8(secret.as_bytes().to_owned()) {
266 Ok(passphrase) => (passphrase.clone(), None),
267 Err(error) => {
268 error!("Could not get secret in stored session: {error}");
269 return Err(LinuxSecretFieldError::Invalid.into());
270 }
271 }
272 }
273 }
274 Err(error) => {
275 error!("Could not get secret in stored session: {error}");
276 return Err(LinuxSecretFieldError::Invalid.into());
277 }
278 };
279
280 let session = Self {
281 homeserver,
282 user_id,
283 device_id,
284 id,
285 client_id,
286 passphrase: passphrase.into(),
287 };
288
289 if version < CURRENT_VERSION {
290 Err(LinuxSecretError::OldVersion {
291 version,
292 session,
293 item,
294 access_token,
295 })
296 } else {
297 Ok(session)
298 }
299 }
300
301 fn attributes(&self) -> HashMap<&'static str, String> {
303 let mut attributes = HashMap::from([
304 (keys::HOMESERVER, self.homeserver.to_string()),
305 (keys::USER, self.user_id.to_string()),
306 (keys::DEVICE_ID, self.device_id.to_string()),
307 (keys::ID, self.id.clone()),
308 (keys::VERSION, CURRENT_VERSION.to_string()),
309 (keys::PROFILE, PROFILE.to_string()),
310 (keys::XDG_SCHEMA, APP_ID.to_owned()),
311 ]);
312
313 if let Some(client_id) = &self.client_id {
314 attributes.insert(keys::CLIENT_ID, client_id.as_str().to_owned());
315 }
316
317 attributes
318 }
319
320 async fn apply_migrations(
322 &mut self,
323 from_version: u8,
324 item: Item,
325 access_token: Option<String>,
326 ) {
327 if from_version < 6 {
332 info!("Migrating to version 6…");
337
338 let old_path = self.data_path();
340
341 self.id.truncate(SESSION_ID_LENGTH);
343 let new_path = self.data_path();
344
345 spawn_tokio!(async move {
346 debug!(
347 "Renaming databases directory to: {}",
348 new_path.to_string_lossy()
349 );
350
351 if let Err(error) = fs::rename(old_path, new_path).await {
352 error!("Could not rename databases directory: {error}");
353 }
354 })
355 .await
356 .expect("task was not aborted");
357 }
358
359 if from_version < 7 {
360 info!("Migrating to version 7…");
363
364 let new_attributes = self.attributes();
365 let new_secret = oo7::Secret::text(&self.passphrase);
366
367 spawn_tokio!(async move {
368 if let Err(error) = item.set_secret(new_secret).await {
369 error!("Could not store updated session secret: {error}");
370 }
371
372 if let Err(error) = item.set_attributes(&new_attributes).await {
373 error!("Could not store updated session attributes: {error}");
374 }
375 })
376 .await
377 .expect("task was not aborted");
378
379 if let Some(access_token) = access_token {
380 let session_tokens = SessionTokens {
381 access_token,
382 refresh_token: None,
383 };
384 self.store_tokens(session_tokens).await;
385 }
386 }
387 }
388}
389
390#[derive(Clone, Deserialize)]
392struct V4SecretData {
393 access_token: String,
395 passphrase: String,
397}
398
399fn get_attribute<'a>(
401 attributes: &'a HashMap<String, String>,
402 key: &'static str,
403) -> Result<&'a String, LinuxSecretFieldError> {
404 attributes
405 .get(key)
406 .ok_or(LinuxSecretFieldError::Missing(key))
407}
408
409fn parse_attribute<F, V, E>(
412 attributes: &HashMap<String, String>,
413 key: &'static str,
414 parse: F,
415) -> Result<V, LinuxSecretFieldError>
416where
417 F: FnOnce(&str) -> Result<V, E>,
418 E: std::fmt::Display,
419{
420 let string = get_attribute(attributes, key)?;
421 match parse(string) {
422 Ok(value) => Ok(value),
423 Err(error) => {
424 error!("Could not parse {key} in stored session: {error}");
425 Err(LinuxSecretFieldError::Invalid)
426 }
427 }
428}
429
430#[derive(Debug, Error)]
433enum LinuxSecretFieldError {
434 #[error("Could not find {0} in stored session")]
439 Missing(&'static str),
440
441 #[error("Invalid field in stored session")]
446 Invalid,
447}
448
449async fn delete_item_with_attributes(
451 attributes: &impl oo7::AsAttributes,
452) -> Result<(), oo7::Error> {
453 let keyring = Keyring::new().await?;
454 keyring.delete(attributes).await?;
455
456 Ok(())
457}
458
459#[derive(Debug, Error)]
462#[allow(clippy::large_enum_variant)]
464enum LinuxSecretError {
465 #[error("Session found with unsupported version {0}")]
467 UnsupportedVersion(u8),
468
469 #[error("Session found with old version")]
471 OldVersion {
472 version: u8,
474 session: StoredSession,
476 item: Item,
478 access_token: Option<String>,
482 },
483
484 #[error(transparent)]
489 Field(#[from] LinuxSecretFieldError),
490
491 #[error(transparent)]
493 Oo7(#[from] oo7::Error),
494}
495
496impl From<oo7::Error> for SecretError {
497 fn from(value: oo7::Error) -> Self {
498 Self::Service(value.to_user_facing())
499 }
500}
501
502impl UserFacingError for oo7::Error {
503 fn to_user_facing(&self) -> String {
504 match self {
505 oo7::Error::File(error) => error.to_user_facing(),
506 oo7::Error::DBus(error) => error.to_user_facing(),
507 }
508 }
509}
510
511impl UserFacingError for oo7::file::Error {
512 fn to_user_facing(&self) -> String {
513 use oo7::file::Error;
514
515 match self {
516 Error::FileHeaderMismatch(_) |
517 Error::VersionMismatch(_) |
518 Error::NoData |
519 Error::MacError |
520 Error::HashedAttributeMac(_) |
521 Error::GVariantDeserialization(_) |
522 Error::SaltSizeMismatch(_, _) |
523 Error::ChecksumMismatch |
524 Error::AlgorithmMismatch(_) |
525 Error::IncorrectSecret |
526 Error::Crypto(_) |
527 Error::Utf8(_) => gettext(
528 "The secret storage file is corrupted.",
529 ),
530 Error::NoParentDir(_) |
531 Error::NoDataDir => gettext(
532 "Could not access the secret storage file location.",
533 ),
534 Error::Io(_) => gettext(
535 "An unexpected error occurred when accessing the secret storage file.",
536 ),
537 Error::TargetFileChanged(_) => gettext(
538 "The secret storage file has been changed by another process.",
539 ),
540 Error::Portal(ashpd::Error::Portal(ashpd::PortalError::Cancelled(_))) => gettext(
541 "The request to the Flatpak Secret Portal was cancelled. Make sure to accept any prompt asking to access it.",
542 ),
543 Error::Portal(ashpd::Error::PortalNotFound(_)) => gettext(
544 "The Flatpak Secret Portal is not available. Make sure xdg-desktop-portal is installed, and it is at least at version 1.5.0.",
545 ),
546 Error::Portal(_) => gettext(
547 "An unexpected error occurred when interacting with the D-Bus Secret Portal backend.",
548 ),
549 Error::WeakKey(_) => gettext(
550 "The Flatpak Secret Portal provided a key that is too weak to be secure.",
551 ),
552 Error::InvalidItemIndex(_) => unreachable!(),
554 }
555 }
556}
557
558impl UserFacingError for oo7::dbus::Error {
559 fn to_user_facing(&self) -> String {
560 use oo7::dbus::{Error, ServiceError};
561
562 match self {
563 Error::Deleted => gettext(
564 "The item was deleted.",
565 ),
566 Error::Service(s) => match s {
567 ServiceError::ZBus(_) => gettext(
568 "An unexpected error occurred when interacting with the D-Bus Secret Service.",
569 ),
570 ServiceError::IsLocked(_) => gettext(
571 "The collection or item is locked.",
572 ),
573 ServiceError::NoSession(_) => gettext(
574 "The D-Bus Secret Service session does not exist.",
575 ),
576 ServiceError::NoSuchObject(_) => gettext(
577 "The collection or item does not exist.",
578 ),
579 },
580 Error::Dismissed => gettext(
581 "The request to the D-Bus Secret Service was cancelled. Make sure to accept any prompt asking to access it.",
582 ),
583 Error::NotFound(_) => gettext(
584 "Could not access the default collection. Make sure a keyring was created and set as default.",
585 ),
586 Error::ZBus(_) |
587 Error::Crypto(_) |
588 Error::IO(_) => gettext(
589 "An unexpected error occurred when interacting with the D-Bus Secret Service.",
590 ),
591 }
592 }
593}