fractal/secret/
linux.rs

1//! Linux API to store the data of a session, using the Secret Service or Secret
2//! portal.
3
4use 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
19/// The current version of the stored session.
20const CURRENT_VERSION: u8 = 7;
21/// The minimum supported version for the stored sessions.
22///
23/// Currently, this matches the version when Fractal 5 was released.
24const MIN_SUPPORTED_VERSION: u8 = 4;
25
26/// Keys used in the Linux secret backend.
27mod keys {
28    /// The attribute for the schema in the Secret Service.
29    pub(super) const XDG_SCHEMA: &str = "xdg:schema";
30    /// The attribute for the profile of the app.
31    pub(super) const PROFILE: &str = "profile";
32    /// The attribute for the version of the stored session.
33    pub(super) const VERSION: &str = "version";
34    /// The attribute for the URL of the homeserver.
35    pub(super) const HOMESERVER: &str = "homeserver";
36    /// The attribute for the user ID.
37    pub(super) const USER: &str = "user";
38    /// The attribute for the device ID.
39    pub(super) const DEVICE_ID: &str = "device-id";
40    /// The deprecated attribute for the database path.
41    pub(super) const DB_PATH: &str = "db-path";
42    /// The attribute for the session ID.
43    pub(super) const ID: &str = "id";
44    /// The attribute for the client ID.
45    pub(super) const CLIENT_ID: &str = "client-id";
46}
47
48/// Secret API under Linux.
49pub(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                    // Try to log it out.
119                    if let Some(access_token) = access_token {
120                        log_out_session(session.clone(), access_token).await;
121                    }
122
123                    // Delete the session from the secret backend.
124                    LinuxSecret::delete_session(&session).await;
125
126                    // Delete the session data folders.
127                    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                // We already log the specific errors for this.
154            }
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                // Translators: Do NOT translate the content between '{' and '}', this is a
174                // variable name.
175                "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
187/// Create a client and log out the given session.
188async 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    /// Build self from an item.
214    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                    // Even if we store the secret as plain text, the file backend always returns a
264                    // blob so let's always treat it as a byte slice.
265                    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    /// Get the attributes from `self`.
302    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    /// Migrate this session to the current version.
321    async fn apply_migrations(
322        &mut self,
323        from_version: u8,
324        item: Item,
325        access_token: Option<String>,
326    ) {
327        // Version 5 changes the serialization of the secret from MessagePack to JSON.
328        // We can ignore the migration because we changed the format of the secret again
329        // in version 7.
330
331        if from_version < 6 {
332            // Version 6 truncates sessions IDs, changing the path of the databases, and
333            // removes the `db-path` attribute to replace it with the `id` attribute.
334            // Because we need to update the `version` in the attributes for version 7, we
335            // only migrate the path here.
336            info!("Migrating to version 6…");
337
338            // Get the old path of the session.
339            let old_path = self.data_path();
340
341            // Truncate the session ID.
342            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            // Version 7 moves the access token to a separate file. Only the passphrase is
361            // stored as the secret now.
362            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/// Secret data that was stored in the secret backend from versions 4 through 6.
391#[derive(Clone, Deserialize)]
392struct V4SecretData {
393    /// The access token to provide to the homeserver for authentication.
394    access_token: String,
395    /// The passphrase used to encrypt the local databases.
396    passphrase: String,
397}
398
399/// Get the attribute with the given key in the given map.
400fn 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
409/// Parse the attribute with the given key, using the given parsing function in
410/// the given map.
411fn 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/// Any error that can happen when retrieving an attribute from the secret
431/// backends on Linux.
432#[derive(Debug, Error)]
433enum LinuxSecretFieldError {
434    /// An attribute is missing.
435    ///
436    /// This should only happen if for some reason we get an item from a
437    /// different application.
438    #[error("Could not find {0} in stored session")]
439    Missing(&'static str),
440
441    /// An invalid attribute was found.
442    ///
443    /// This should only happen if for some reason we get an item from a
444    /// different application.
445    #[error("Invalid field in stored session")]
446    Invalid,
447}
448
449/// Remove the item with the given attributes from the secret backend.
450async 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/// Any error that can happen when interacting with the secret backends on
460/// Linux.
461#[derive(Debug, Error)]
462// Complains about StoredSession in OldVersion, but we need it.
463#[allow(clippy::large_enum_variant)]
464enum LinuxSecretError {
465    /// A session with an unsupported version was found.
466    #[error("Session found with unsupported version {0}")]
467    UnsupportedVersion(u8),
468
469    /// A session with an old version was found.
470    #[error("Session found with old version")]
471    OldVersion {
472        /// The version that was found.
473        version: u8,
474        /// The session that was found.
475        session: StoredSession,
476        /// The item for the session.
477        item: Item,
478        /// The access token that was found.
479        ///
480        /// It needs to be stored outside of the secret backend now.
481        access_token: Option<String>,
482    },
483
484    /// An error occurred while retrieving a field of the session.
485    ///
486    /// This should only happen if for some reason we get an item from a
487    /// different application.
488    #[error(transparent)]
489    Field(#[from] LinuxSecretFieldError),
490
491    /// An error occurred while interacting with the secret backend.
492    #[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            // Can only occur when using the `replace_item_index` or `delete_item_index` methods.
553            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}