rtb-credentials 0.6.6

OS keychain credential storage. Part of the phpboyscout Rust toolkit.
Documentation
//! The [`CredentialStore`] trait and its built-in implementations.

use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};

use async_trait::async_trait;
use secrecy::{ExposeSecret, SecretString};

use crate::error::CredentialError;

/// Backend-agnostic contract for credential storage.
///
/// Every method is `async` because some real backends (particularly
/// the platform keyring on Windows and macOS) perform blocking system
/// calls; wrapping in `spawn_blocking` keeps the trait usable in any
/// async context.
#[async_trait]
pub trait CredentialStore: Send + Sync + 'static {
    /// Retrieve a secret by `service`/`account`. Returns
    /// [`CredentialError::NotFound`] when the store does not carry
    /// it.
    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError>;

    /// Store (or overwrite) a secret at `service`/`account`.
    /// Returns [`CredentialError::ReadOnly`] on stores that do not
    /// support mutation.
    async fn set(
        &self,
        service: &str,
        account: &str,
        secret: SecretString,
    ) -> Result<(), CredentialError>;

    /// Remove a secret. Missing entries are not an error. Returns
    /// [`CredentialError::ReadOnly`] on stores that do not support
    /// mutation.
    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
}

// =====================================================================
// MemoryStore — HashMap-backed, test-friendly.
// =====================================================================

/// In-memory store. Ideal for tests and for downstream crates that
/// need a `dyn CredentialStore` without touching the OS keychain.
#[derive(Default)]
pub struct MemoryStore {
    inner: RwLock<HashMap<(String, String), SecretString>>,
}

impl std::fmt::Debug for MemoryStore {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MemoryStore").finish_non_exhaustive()
    }
}

impl MemoryStore {
    /// Create a fresh empty store.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl CredentialStore for MemoryStore {
    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError> {
        let map = self.inner.read().map_err(|_| poisoned())?;
        map.get(&(service.to_string(), account.to_string()))
            .cloned()
            .ok_or_else(|| CredentialError::NotFound { name: format!("{service}/{account}") })
    }

    async fn set(
        &self,
        service: &str,
        account: &str,
        secret: SecretString,
    ) -> Result<(), CredentialError> {
        {
            let mut map = self.inner.write().map_err(|_| poisoned())?;
            map.insert((service.to_string(), account.to_string()), secret);
        }
        Ok(())
    }

    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
        {
            let mut map = self.inner.write().map_err(|_| poisoned())?;
            map.remove(&(service.to_string(), account.to_string()));
        }
        Ok(())
    }
}

fn poisoned() -> CredentialError {
    CredentialError::Keychain("in-memory lock poisoned".to_string())
}

// =====================================================================
// EnvStore — read-through of process env.
// =====================================================================

/// Reads secrets straight from process environment variables.
///
/// The `service` argument is ignored; `account` is interpreted as the
/// env-var name. `set` / `delete` are deliberately unsupported:
/// mutating process env from library code is `unsafe` in Rust 2024
/// and cross-thread-unsound on every platform.
#[derive(Debug, Default)]
pub struct EnvStore;

impl EnvStore {
    /// Construct a new env-backed store.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

#[async_trait]
impl CredentialStore for EnvStore {
    async fn get(&self, _service: &str, account: &str) -> Result<SecretString, CredentialError> {
        std::env::var(account)
            .map(SecretString::from)
            .map_err(|_| CredentialError::NotFound { name: account.to_string() })
    }

    async fn set(&self, _: &str, _: &str, _: SecretString) -> Result<(), CredentialError> {
        Err(CredentialError::ReadOnly)
    }

    async fn delete(&self, _: &str, _: &str) -> Result<(), CredentialError> {
        Err(CredentialError::ReadOnly)
    }
}

// =====================================================================
// LiteralStore — a single fixed secret.
// =====================================================================

/// Stores a single fixed secret and ignores `service`/`account` on
/// `get`. Useful when a tool is hard-wired to a single credential
/// (e.g. test harnesses).
pub struct LiteralStore {
    secret: SecretString,
}

impl std::fmt::Debug for LiteralStore {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LiteralStore").finish_non_exhaustive()
    }
}

impl LiteralStore {
    /// Wrap a literal secret.
    #[must_use]
    pub const fn new(secret: SecretString) -> Self {
        Self { secret }
    }
}

#[async_trait]
impl CredentialStore for LiteralStore {
    async fn get(&self, _: &str, _: &str) -> Result<SecretString, CredentialError> {
        // `SecretString` is `Clone`; cloning produces a new
        // zeroize-on-drop container without bouncing through a
        // bare `String` intermediate.
        Ok(self.secret.clone())
    }

    async fn set(&self, _: &str, _: &str, _: SecretString) -> Result<(), CredentialError> {
        Err(CredentialError::ReadOnly)
    }

    async fn delete(&self, _: &str, _: &str) -> Result<(), CredentialError> {
        Err(CredentialError::ReadOnly)
    }
}

// =====================================================================
// KeyringStore — platform-native via `keyring-core`.
// =====================================================================

/// OS-keychain-backed store: macOS Keychain, Windows Credential Manager,
/// or the freedesktop Secret Service on Linux and the BSDs.
///
/// Blocking keyring calls are wrapped in
/// `tokio::task::spawn_blocking` to keep the async trait honest on
/// any runtime.
#[derive(Debug, Default)]
pub struct KeyringStore;

impl KeyringStore {
    /// Create a new keyring-backed store. No handles are held until
    /// the first call.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

#[async_trait]
impl CredentialStore for KeyringStore {
    async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError> {
        let service = service.to_string();
        let account = account.to_string();
        tokio::task::spawn_blocking(move || -> Result<SecretString, CredentialError> {
            match platform_entry(&service, &account)?.get_password() {
                Ok(pw) => Ok(SecretString::from(pw)),
                Err(keyring_core::Error::NoEntry) => {
                    Err(CredentialError::NotFound { name: format!("{service}/{account}") })
                }
                Err(e) => Err(CredentialError::Keychain(e.to_string())),
            }
        })
        .await
        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
    }

    async fn set(
        &self,
        service: &str,
        account: &str,
        secret: SecretString,
    ) -> Result<(), CredentialError> {
        let service = service.to_string();
        let account = account.to_string();
        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
            platform_entry(&service, &account)?
                .set_password(secret.expose_secret())
                .map_err(|e| CredentialError::Keychain(e.to_string()))
        })
        .await
        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
    }

    async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError> {
        let service = service.to_string();
        let account = account.to_string();
        tokio::task::spawn_blocking(move || -> Result<(), CredentialError> {
            match platform_entry(&service, &account)?.delete_credential() {
                Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
                Err(e) => Err(CredentialError::Keychain(e.to_string())),
            }
        })
        .await
        .map_err(|e| CredentialError::Keychain(format!("join error: {e}")))?
    }
}

/// Builds the entry from a store this crate owns rather than through
/// `keyring_core::set_default_store`, so a host application's own choice
/// of default store is left alone.
fn platform_entry(service: &str, account: &str) -> Result<keyring_core::Entry, CredentialError> {
    platform_store()?
        .build(service, account, None)
        .map_err(|e| CredentialError::Keychain(e.to_string()))
}

static PLATFORM_STORE: OnceLock<Arc<keyring_core::CredentialStore>> = OnceLock::new();

/// A failed connection is not cached: a session bus or keyring daemon that
/// starts later in the process is picked up on the next call.
fn platform_store() -> Result<&'static Arc<keyring_core::CredentialStore>, CredentialError> {
    if let Some(store) = PLATFORM_STORE.get() {
        return Ok(store);
    }
    let store = new_platform_store().map_err(|e| CredentialError::Keychain(e.to_string()))?;
    Ok(PLATFORM_STORE.get_or_init(|| store))
}

#[cfg(target_os = "macos")]
fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
    Ok(apple_native_keyring_store::keychain::Store::new()?)
}

#[cfg(target_os = "windows")]
fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
    Ok(windows_native_keyring_store::Store::new()?)
}

#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
    Ok(dbus_secret_service_keyring_store::Store::new()?)
}

#[cfg(not(any(
    target_os = "macos",
    target_os = "windows",
    all(unix, not(any(target_os = "ios", target_os = "android")))
)))]
fn new_platform_store() -> keyring_core::Result<Arc<keyring_core::CredentialStore>> {
    Err(keyring_core::Error::NoDefaultStore)
}