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
23pub 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}