quvyta-framework 0.1.35

A Rust framework for building terminal applications
//! Passwords, tokens and session keys kept where they belong: in the system keyring.
//!
//! An application that signs in to an account has something it must remember and must never write
//! to a file in plain text. [`Secrets`] keeps it in the keyring the desktop already has, the
//! freedesktop Secret Service that GNOME Keyring, KWallet and KeePassXC all answer to, under two
//! attributes: `application`, the name the application gives, and `account`, the key it keeps the
//! secret under. The label is what a person reads in their keyring program, such as
//! `Music: Home server`.
//!
//! ```
//! use qframe::secrets::Secrets;
//!
//! // A machine without a keyring, a test or a demo: kept in memory, gone when the process ends.
//! let secrets = Secrets::memory("quvyta-music");
//! secrets.store("home-server", "Music: Home server", b"correct horse").unwrap();
//! let secret = secrets.lookup("home-server").unwrap();
//! assert_eq!(secret.as_deref().map(Vec::as_slice), Some(&b"correct horse"[..]));
//! secrets.delete("home-server").unwrap();
//! assert!(secrets.lookup("home-server").unwrap().is_none());
//! ```
//!
//! An application asks for the system keyring once and keeps the answer; where there is none it
//! says so plainly and offers the in-memory store, or asks again each run:
//!
//! ```no_run
//! use qframe::secrets::{SecretError, Secrets};
//!
//! let secrets = match Secrets::system("quvyta-music") {
//!     Ok(keyring) => keyring,
//!     Err(SecretError::Unavailable) => Secrets::memory("quvyta-music"),
//!     Err(other) => panic!("{other}"),
//! };
//! # let _ = secrets;
//! ```
//!
//! # Blocking
//!
//! Every call talks to another process and may wait for it, and a locked keyring waits for the
//! person to type its password into the keyring's own prompt. None of them belongs on the thread
//! that draws: call them from [`Command::perform`](crate::runtime::Command::perform) or a
//! [`Task`](crate::runtime::Task) and hand the result back as a message.
//!
//! # What never happens
//!
//! A secret is never written to disk by this module, never put into an error, a log line or the
//! `Debug` output of anything here, and the bytes handed back are wiped from memory when they are
//! dropped ([`Zeroizing`]). The framework never draws a password prompt of its own for the keyring:
//! unlocking is the keyring's business, through its own prompt.
//!
//! The Secret Service is offered on Linux and the BSDs. Elsewhere, and wherever the session has no
//! D-Bus (`DBUS_SESSION_BUS_ADDRESS` unset or empty: an SSH login, a container, a test run that
//! isolates itself from the desktop), [`Secrets::system`] answers [`SecretError::Unavailable`] at
//! once, without waiting.

use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex, PoisonError};

pub use zeroize::Zeroizing;

#[cfg(unix)]
mod service;
#[cfg(all(test, unix))]
mod tests;

/// A place to keep secrets: the system keyring, or memory.
///
/// Cloning is cheap and the clones share the same keyring or the same memory, so one can be handed
/// to every background task that needs it.
#[derive(Clone)]
pub struct Secrets {
    application: String,
    store: Store,
}

#[derive(Clone)]
enum Store {
    Memory(Arc<Mutex<HashMap<String, Zeroizing<Vec<u8>>>>>),
    #[cfg(unix)]
    System(service::Keyring),
}

impl Secrets {
    /// The system keyring, reached over the session's D-Bus, for the application named
    /// `application`. Its items carry that name as their `application` attribute; the Quvyta
    /// ecosystem writes `quvyta-<app>`, such as `quvyta-music`.
    ///
    /// This only opens a session with the keyring; it neither unlocks it nor asks the person
    /// anything.
    ///
    /// # Errors
    ///
    /// [`SecretError::Unavailable`] at once when the session has no D-Bus or nothing on it answers
    /// as the Secret Service, and on systems that have none; [`SecretError::Other`] when the
    /// keyring refuses the session for a reason of its own.
    pub fn system(application: &str) -> Result<Self, SecretError> {
        #[cfg(unix)]
        {
            let address = std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_default();
            let keyring = service::Keyring::open(&address)?;
            Ok(Self { application: application.to_owned(), store: Store::System(keyring) })
        }
        #[cfg(not(unix))]
        {
            let _ = application;
            Err(SecretError::Unavailable)
        }
    }

    /// Secrets kept only in this process's memory and gone when it ends: for tests, demos and
    /// machines with no keyring, where the alternative is asking again each run.
    #[must_use]
    pub fn memory(application: &str) -> Self {
        Self { application: application.to_owned(), store: Store::Memory(Arc::default()) }
    }

    /// Whether these secrets live in the system keyring rather than in memory, for a settings
    /// page that tells the person where their password went.
    #[must_use]
    pub fn is_system(&self) -> bool {
        !matches!(self.store, Store::Memory(_))
    }

    /// Keeps `secret` under `account`, replacing what was kept there before. `label` is the name
    /// the person reads in their keyring program.
    ///
    /// A locked keyring is unlocked first, through the keyring's own prompt.
    ///
    /// # Errors
    ///
    /// [`SecretError::Denied`] when the person dismisses the keyring's prompt,
    /// [`SecretError::Locked`] when the keyring stays locked, [`SecretError::Other`] for anything
    /// else the keyring answers.
    pub fn store(&self, account: &str, label: &str, secret: &[u8]) -> Result<(), SecretError> {
        match &self.store {
            Store::Memory(kept) => {
                let _ = label;
                lock(kept).insert(account.to_owned(), Zeroizing::new(secret.to_vec()));
                Ok(())
            }
            #[cfg(unix)]
            Store::System(keyring) => keyring.store(&self.application, account, label, secret),
        }
    }

    /// The secret kept under `account`, or `None` when nothing is.
    ///
    /// # Errors
    ///
    /// As for [`Secrets::store`]: the secret may sit in a locked keyring that the person declines
    /// to open.
    pub fn lookup(&self, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, SecretError> {
        match &self.store {
            Store::Memory(kept) => Ok(lock(kept).get(account).cloned()),
            #[cfg(unix)]
            Store::System(keyring) => keyring.lookup(&self.application, account),
        }
    }

    /// Forgets the secret kept under `account`. Forgetting an account that has none is not an
    /// error, so signing out twice is harmless.
    ///
    /// # Errors
    ///
    /// As for [`Secrets::store`].
    pub fn delete(&self, account: &str) -> Result<(), SecretError> {
        match &self.store {
            Store::Memory(kept) => {
                lock(kept).remove(account);
                Ok(())
            }
            #[cfg(unix)]
            Store::System(keyring) => keyring.delete(&self.application, account),
        }
    }
}

/// The memory store, even after a panic elsewhere left its lock poisoned: a map of owned values
/// has no half-made state to protect.
fn lock(
    kept: &Mutex<HashMap<String, Zeroizing<Vec<u8>>>>,
) -> std::sync::MutexGuard<'_, HashMap<String, Zeroizing<Vec<u8>>>> {
    kept.lock().unwrap_or_else(PoisonError::into_inner)
}

impl fmt::Debug for Secrets {
    // Written by hand so that no secret, and no account name of the memory store, is ever printed.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let place = if self.is_system() { "system keyring" } else { "memory" };
        f.debug_struct("Secrets").field("application", &self.application).field("store", &place).finish()
    }
}

/// Why the keyring did not do what was asked. None of them ever carries the secret.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SecretError {
    /// There is no keyring to ask: no D-Bus in this session, nothing on it answering as the
    /// Secret Service, or a system that has none.
    Unavailable,
    /// The keyring is locked and was not opened, because its prompt did not finish in time.
    Locked,
    /// The keyring refused, or the person dismissed its prompt.
    Denied,
    /// Anything else, in the keyring's or the system's own words.
    Other(String),
}

impl fmt::Display for SecretError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Unavailable => f.write_str("there is no system keyring to keep secrets in"),
            Self::Locked => f.write_str("the keyring is locked and was not opened"),
            Self::Denied => f.write_str("the keyring refused, or its prompt was dismissed"),
            Self::Other(reason) => write!(f, "the keyring answered: {reason}"),
        }
    }
}

impl std::error::Error for SecretError {}