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}