Skip to main content

harn_vm/secrets/
keyring.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::{Arc, Mutex, OnceLock};
4
5use async_trait::async_trait;
6use keyring_core::{CredentialStore, Entry, Error as KeyringError};
7
8use super::{
9    emit_secret_access_event, ensure_scoped_secret_access_allowed, RotationHandle, SecretBytes,
10    SecretDeleteRequest, SecretError, SecretId, SecretMeta, SecretProvider,
11};
12
13static PLATFORM_STORE: OnceLock<Arc<CredentialStore>> = OnceLock::new();
14
15#[derive(Debug, thiserror::Error)]
16pub enum NativeKeyringError {
17    #[error(transparent)]
18    Keyring(#[from] KeyringError),
19    #[error("credential contains invalid UTF-8: {0}")]
20    Utf8(#[from] std::string::FromUtf8Error),
21}
22
23/// Cross-platform access to the operating system's native credential store.
24///
25/// The keyring ecosystem owns the platform mappings and secure-storage API
26/// calls. Harn only supplies the stable `(service, user)` namespace used by its
27/// runtime and host capability.
28pub struct NativeKeyring {
29    service: String,
30    entries: Mutex<HashMap<String, Arc<Entry>>>,
31    store: Option<Arc<CredentialStore>>,
32}
33
34impl fmt::Debug for NativeKeyring {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("NativeKeyring")
38            .field("service", &self.service)
39            .finish_non_exhaustive()
40    }
41}
42
43impl NativeKeyring {
44    pub fn new(service: impl Into<String>) -> Self {
45        Self {
46            service: service.into(),
47            entries: Mutex::new(HashMap::new()),
48            store: None,
49        }
50    }
51
52    #[cfg(test)]
53    fn with_store(service: impl Into<String>, store: Arc<CredentialStore>) -> Self {
54        Self {
55            service: service.into(),
56            entries: Mutex::new(HashMap::new()),
57            store: Some(store),
58        }
59    }
60
61    pub fn service(&self) -> &str {
62        &self.service
63    }
64
65    pub fn get(&self, user: &str) -> Result<Option<Vec<u8>>, NativeKeyringError> {
66        match self.entry(user)?.get_secret() {
67            Ok(secret) => Ok(Some(secret)),
68            Err(KeyringError::NoEntry) => Ok(None),
69            Err(error) => Err(error.into()),
70        }
71    }
72
73    pub fn get_string(&self, user: &str) -> Result<Option<String>, NativeKeyringError> {
74        self.get(user)?
75            .map(String::from_utf8)
76            .transpose()
77            .map_err(Into::into)
78    }
79
80    pub fn set(&self, user: &str, secret: &[u8]) -> Result<(), NativeKeyringError> {
81        self.entry(user)?.set_secret(secret).map_err(Into::into)
82    }
83
84    pub fn set_string(&self, user: &str, secret: &str) -> Result<(), NativeKeyringError> {
85        self.set(user, secret.as_bytes())
86    }
87
88    pub fn delete(&self, user: &str) -> Result<bool, NativeKeyringError> {
89        match self.entry(user)?.delete_credential() {
90            Ok(()) => Ok(true),
91            Err(KeyringError::NoEntry) => Ok(false),
92            Err(error) => Err(error.into()),
93        }
94    }
95
96    pub fn list(&self) -> Result<Vec<String>, NativeKeyringError> {
97        let store = self.store()?;
98        #[cfg(target_os = "windows")]
99        let pattern = format!(r"\.{}$", regex::escape(&self.service));
100        #[cfg(target_os = "windows")]
101        let spec = HashMap::from([("pattern", pattern.as_str())]);
102        #[cfg(not(target_os = "windows"))]
103        let spec = HashMap::from([("service", self.service.as_str())]);
104        let mut users = store
105            .search(&spec)?
106            .into_iter()
107            .filter_map(|entry| entry.get_specifiers())
108            .filter_map(|(service, user)| (service == self.service).then_some(user))
109            .collect::<Vec<_>>();
110        users.sort();
111        users.dedup();
112        Ok(users)
113    }
114
115    pub fn healthcheck(&self) -> Result<String, NativeKeyringError> {
116        let _ = self.get("__harn_probe__")?;
117        Ok(format!("service '{}' reachable", self.service))
118    }
119
120    fn entry(&self, user: &str) -> Result<Arc<Entry>, NativeKeyringError> {
121        let mut entries = self.entries.lock().expect("keyring cache poisoned");
122        if let Some(entry) = entries.get(user) {
123            return Ok(entry.clone());
124        }
125        let entry = Arc::new(self.store()?.build(self.service(), user, None)?);
126        entries.insert(user.to_string(), entry.clone());
127        Ok(entry)
128    }
129
130    fn store(&self) -> Result<Arc<CredentialStore>, NativeKeyringError> {
131        if let Some(store) = &self.store {
132            return Ok(store.clone());
133        }
134        if let Some(store) = PLATFORM_STORE.get() {
135            return Ok(store.clone());
136        }
137        let store = platform_store()?;
138        let _ = PLATFORM_STORE.set(store.clone());
139        Ok(PLATFORM_STORE.get().cloned().unwrap_or(store))
140    }
141}
142
143fn platform_store() -> Result<Arc<CredentialStore>, NativeKeyringError> {
144    #[cfg(all(feature = "native-keyring", target_os = "macos"))]
145    {
146        let store: Arc<CredentialStore> = apple_native_keyring_store::keychain::Store::new()?;
147        return Ok(store);
148    }
149    #[cfg(all(feature = "native-keyring", target_os = "ios"))]
150    {
151        let store: Arc<CredentialStore> = apple_native_keyring_store::protected::Store::new()?;
152        return Ok(store);
153    }
154    #[cfg(all(feature = "native-keyring", target_os = "windows"))]
155    {
156        let store: Arc<CredentialStore> = windows_native_keyring_store::Store::new()?;
157        return Ok(store);
158    }
159    #[cfg(all(
160        feature = "native-keyring",
161        unix,
162        not(any(target_os = "macos", target_os = "ios", target_os = "android"))
163    ))]
164    {
165        let store: Arc<CredentialStore> = zbus_secret_service_keyring_store::Store::new()?;
166        return Ok(store);
167    }
168    #[allow(unreachable_code)]
169    Err(KeyringError::NoDefaultStore.into())
170}
171
172#[derive(Debug)]
173pub struct KeyringSecretProvider {
174    keyring: NativeKeyring,
175}
176
177impl KeyringSecretProvider {
178    pub fn new(namespace: impl Into<String>) -> Self {
179        Self {
180            keyring: NativeKeyring::new(namespace),
181        }
182    }
183
184    #[cfg(test)]
185    pub(super) fn with_store(namespace: impl Into<String>, store: Arc<CredentialStore>) -> Self {
186        Self {
187            keyring: NativeKeyring::with_store(namespace, store),
188        }
189    }
190
191    pub fn service(&self) -> &str {
192        self.keyring.service()
193    }
194
195    pub async fn delete(&self, id: &SecretId) -> Result<(), SecretError> {
196        self.keyring
197            .delete(&account_name(id))
198            .map(|_| ())
199            .map_err(|error| backend_error("delete", error))
200    }
201
202    pub fn healthcheck(&self) -> Result<String, SecretError> {
203        self.keyring
204            .healthcheck()
205            .map_err(|error| backend_error("access", error))
206    }
207}
208
209#[async_trait]
210impl SecretProvider for KeyringSecretProvider {
211    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
212        match self
213            .keyring
214            .get(&account_name(id))
215            .map_err(|error| backend_error("read", error))?
216        {
217            Some(bytes) => {
218                emit_secret_access_event("keyring", id);
219                Ok(SecretBytes::from(bytes))
220            }
221            None => Err(SecretError::NotFound {
222                provider: "keyring".to_string(),
223                id: id.clone(),
224            }),
225        }
226    }
227
228    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
229        value.with_exposed(|bytes| {
230            self.keyring
231                .set(&account_name(id), bytes)
232                .map_err(|error| backend_error("store", error))
233        })
234    }
235
236    async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
237        Err(SecretError::Unsupported {
238            provider: "keyring".to_string(),
239            operation: "rotate",
240        })
241    }
242
243    async fn delete_scoped(&self, request: SecretDeleteRequest) -> Result<(), SecretError> {
244        ensure_scoped_secret_access_allowed("delete", &request.id)?;
245        self.delete(&request.id).await
246    }
247
248    async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
249        Err(SecretError::Unsupported {
250            provider: "keyring".to_string(),
251            operation: "list",
252        })
253    }
254
255    fn namespace(&self) -> &str {
256        self.service()
257    }
258
259    fn supports_versions(&self) -> bool {
260        false
261    }
262}
263
264fn backend_error(operation: &str, error: NativeKeyringError) -> SecretError {
265    SecretError::Backend {
266        provider: "keyring".to_string(),
267        message: format!("failed to {operation} keyring credential: {error}"),
268    }
269}
270
271fn account_name(id: &SecretId) -> String {
272    let mut account = String::new();
273    if !id.namespace.is_empty() {
274        account.push_str(&sanitize_component(&id.namespace));
275        account.push('/');
276    }
277    account.push_str(&sanitize_component(&id.name));
278    match id.version {
279        super::SecretVersion::Latest => {}
280        super::SecretVersion::Exact(version) => {
281            account.push('#');
282            account.push('v');
283            account.push_str(&version.to_string());
284        }
285    }
286    account
287}
288
289fn sanitize_component(value: &str) -> String {
290    let normalized = value
291        .chars()
292        .map(|ch| {
293            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/') {
294                ch
295            } else {
296                '_'
297            }
298        })
299        .collect::<String>();
300    if normalized.is_empty() {
301        "_".to_string()
302    } else {
303        normalized
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn native_keyring_round_trips_and_lists_service_users() {
313        let keyring = NativeKeyring::with_store(
314            "harn.native-test",
315            keyring_core::mock::Store::new().unwrap(),
316        );
317        keyring.set_string("alpha", "one").unwrap();
318        keyring.set_string("beta", "two").unwrap();
319
320        assert_eq!(keyring.get_string("alpha").unwrap().as_deref(), Some("one"));
321        assert_eq!(keyring.list().unwrap(), vec!["alpha", "beta"]);
322        assert!(keyring.delete("alpha").unwrap());
323        assert!(!keyring.delete("alpha").unwrap());
324    }
325}