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(target_os = "macos")]
145    {
146        let store: Arc<CredentialStore> = apple_native_keyring_store::keychain::Store::new()?;
147        return Ok(store);
148    }
149    #[cfg(target_os = "ios")]
150    {
151        let store: Arc<CredentialStore> = apple_native_keyring_store::protected::Store::new()?;
152        return Ok(store);
153    }
154    #[cfg(target_os = "windows")]
155    {
156        let store: Arc<CredentialStore> = windows_native_keyring_store::Store::new()?;
157        return Ok(store);
158    }
159    #[cfg(all(
160        unix,
161        not(any(target_os = "macos", target_os = "ios", target_os = "android"))
162    ))]
163    {
164        let store: Arc<CredentialStore> = zbus_secret_service_keyring_store::Store::new()?;
165        return Ok(store);
166    }
167    #[allow(unreachable_code)]
168    Err(KeyringError::NoDefaultStore.into())
169}
170
171#[derive(Debug)]
172pub struct KeyringSecretProvider {
173    keyring: NativeKeyring,
174}
175
176impl KeyringSecretProvider {
177    pub fn new(namespace: impl Into<String>) -> Self {
178        Self {
179            keyring: NativeKeyring::new(namespace),
180        }
181    }
182
183    #[cfg(test)]
184    pub(super) fn with_store(namespace: impl Into<String>, store: Arc<CredentialStore>) -> Self {
185        Self {
186            keyring: NativeKeyring::with_store(namespace, store),
187        }
188    }
189
190    pub fn service(&self) -> &str {
191        self.keyring.service()
192    }
193
194    pub async fn delete(&self, id: &SecretId) -> Result<(), SecretError> {
195        self.keyring
196            .delete(&account_name(id))
197            .map(|_| ())
198            .map_err(|error| backend_error("delete", error))
199    }
200
201    pub fn healthcheck(&self) -> Result<String, SecretError> {
202        self.keyring
203            .healthcheck()
204            .map_err(|error| backend_error("access", error))
205    }
206}
207
208#[async_trait]
209impl SecretProvider for KeyringSecretProvider {
210    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
211        match self
212            .keyring
213            .get(&account_name(id))
214            .map_err(|error| backend_error("read", error))?
215        {
216            Some(bytes) => {
217                emit_secret_access_event("keyring", id);
218                Ok(SecretBytes::from(bytes))
219            }
220            None => Err(SecretError::NotFound {
221                provider: "keyring".to_string(),
222                id: id.clone(),
223            }),
224        }
225    }
226
227    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
228        value.with_exposed(|bytes| {
229            self.keyring
230                .set(&account_name(id), bytes)
231                .map_err(|error| backend_error("store", error))
232        })
233    }
234
235    async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
236        Err(SecretError::Unsupported {
237            provider: "keyring".to_string(),
238            operation: "rotate",
239        })
240    }
241
242    async fn delete_scoped(&self, request: SecretDeleteRequest) -> Result<(), SecretError> {
243        ensure_scoped_secret_access_allowed("delete", &request.id)?;
244        self.delete(&request.id).await
245    }
246
247    async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
248        Err(SecretError::Unsupported {
249            provider: "keyring".to_string(),
250            operation: "list",
251        })
252    }
253
254    fn namespace(&self) -> &str {
255        self.service()
256    }
257
258    fn supports_versions(&self) -> bool {
259        false
260    }
261}
262
263fn backend_error(operation: &str, error: NativeKeyringError) -> SecretError {
264    SecretError::Backend {
265        provider: "keyring".to_string(),
266        message: format!("failed to {operation} keyring credential: {error}"),
267    }
268}
269
270fn account_name(id: &SecretId) -> String {
271    let mut account = String::new();
272    if !id.namespace.is_empty() {
273        account.push_str(&sanitize_component(&id.namespace));
274        account.push('/');
275    }
276    account.push_str(&sanitize_component(&id.name));
277    match id.version {
278        super::SecretVersion::Latest => {}
279        super::SecretVersion::Exact(version) => {
280            account.push('#');
281            account.push('v');
282            account.push_str(&version.to_string());
283        }
284    }
285    account
286}
287
288fn sanitize_component(value: &str) -> String {
289    let normalized = value
290        .chars()
291        .map(|ch| {
292            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/') {
293                ch
294            } else {
295                '_'
296            }
297        })
298        .collect::<String>();
299    if normalized.is_empty() {
300        "_".to_string()
301    } else {
302        normalized
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn native_keyring_round_trips_and_lists_service_users() {
312        let keyring = NativeKeyring::with_store(
313            "harn.native-test",
314            keyring_core::mock::Store::new().unwrap(),
315        );
316        keyring.set_string("alpha", "one").unwrap();
317        keyring.set_string("beta", "two").unwrap();
318
319        assert_eq!(keyring.get_string("alpha").unwrap().as_deref(), Some("one"));
320        assert_eq!(keyring.list().unwrap(), vec!["alpha", "beta"]);
321        assert!(keyring.delete("alpha").unwrap());
322        assert!(!keyring.delete("alpha").unwrap());
323    }
324}