Skip to main content

evault_store_keyring/
store.rs

1//! [`OsKeyringSecretStore`] — wraps `keyring_core::Entry` to satisfy the
2//! [`evault_core::traits::SecretStore`] trait contract.
3
4use std::sync::OnceLock;
5
6use keyring_core::Entry;
7
8use evault_core::crypto::{ExposeSecret, SecretString};
9use evault_core::error::SecretError;
10use evault_core::model::VarId;
11use evault_core::traits::SecretStore;
12
13use crate::errors::{is_not_found, map};
14
15/// Canonical service identifier under which `evault` stores all secrets in
16/// the OS native keyring.
17pub const DEFAULT_SERVICE: &str = "evault";
18
19/// `SecretStore` impl backed by the platform's native credential store.
20///
21/// Each variable's value is stored as a single credential keyed by the
22/// variable's UUID. See the crate-level docs for the per-platform mapping.
23///
24/// # Examples
25/// ```ignore
26/// use evault_store_keyring::OsKeyringSecretStore;
27///
28/// let store = OsKeyringSecretStore::new().expect("init keyring");
29/// # let _ = store;
30/// ```
31pub struct OsKeyringSecretStore {
32    service: String,
33}
34
35impl std::fmt::Debug for OsKeyringSecretStore {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        // Never print the service identifier or any credential text. The
38        // service is recoverable from the constructor; including it here
39        // would let it bleed into logs.
40        f.debug_struct("OsKeyringSecretStore")
41            .finish_non_exhaustive()
42    }
43}
44
45impl OsKeyringSecretStore {
46    /// Construct a store using the canonical `"evault"` service identifier.
47    ///
48    /// Initializes the platform-native backend on first call; subsequent
49    /// calls re-use the same backend.
50    ///
51    /// # Errors
52    /// Returns [`SecretError::Unavailable`] if the platform offers no usable
53    /// secret store (e.g. headless Linux without D-Bus). Returns
54    /// [`SecretError::Backend`] for other initialisation failures.
55    pub fn new() -> Result<Self, SecretError> {
56        Self::with_service(DEFAULT_SERVICE)
57    }
58
59    /// Construct a store using a caller-supplied service identifier.
60    /// Useful for tests that need to isolate from any production data.
61    ///
62    /// # Errors
63    /// Returns [`SecretError::Backend`] with label `"empty_service"` if
64    /// the service identifier is empty (platform behaviour for the empty
65    /// case is inconsistent — Windows DPAPI accepts it, Linux Secret
66    /// Service may reject it, macOS Keychain may silently accept). The
67    /// remaining error cases are the same as [`Self::new`].
68    pub fn with_service(service: impl Into<String>) -> Result<Self, SecretError> {
69        let service = service.into();
70        if service.is_empty() {
71            return Err(SecretError::Backend("empty_service".into()));
72        }
73        ensure_native_backend()?;
74        Ok(Self { service })
75    }
76
77    fn entry(&self, id: VarId) -> Result<Entry, SecretError> {
78        Entry::new(&self.service, &id.as_uuid().to_string()).map_err(map)
79    }
80}
81
82/// Initialize the OS-native credential store on first call; cache the
83/// result so subsequent calls are a single atomic load.
84///
85/// The `keyring` 4.x API requires a one-time global selector. We pass
86/// `true` so Linux uses Secret Service (`gnome-keyring` / `KWallet`) rather
87/// than kernel keyutils — the latter does not persist across reboots,
88/// which would silently drop every secret on next boot.
89fn ensure_native_backend() -> Result<(), SecretError> {
90    static INIT: OnceLock<Result<(), SecretError>> = OnceLock::new();
91    let result = INIT.get_or_init(|| keyring::use_native_store(true).map_err(map));
92    match result {
93        Ok(()) => Ok(()),
94        Err(SecretError::Unavailable) => Err(SecretError::Unavailable),
95        Err(SecretError::Backend(s)) => Err(SecretError::Backend(s.clone())),
96        Err(_) => Err(SecretError::Backend("keyring init".into())),
97    }
98}
99
100impl SecretStore for OsKeyringSecretStore {
101    fn put(&self, id: VarId, value: SecretString) -> Result<(), SecretError> {
102        let entry = self.entry(id)?;
103        entry.set_password(value.expose_secret()).map_err(map)
104    }
105
106    fn get(&self, id: VarId) -> Result<Option<SecretString>, SecretError> {
107        let entry = self.entry(id)?;
108        match entry.get_password() {
109            Ok(s) => Ok(Some(SecretString::from(s))),
110            Err(e) if is_not_found(&e) => Ok(None),
111            Err(e) => Err(map(e)),
112        }
113    }
114
115    fn delete(&self, id: VarId) -> Result<(), SecretError> {
116        let entry = self.entry(id)?;
117        match entry.delete_credential() {
118            Ok(()) => Ok(()),
119            Err(e) if is_not_found(&e) => Ok(()),
120            Err(e) => Err(map(e)),
121        }
122    }
123}