1use std::sync::Arc;
50
51use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue, WritableSecretStore};
52use zeroize::Zeroizing;
53
54const BACKEND: &str = "keyring";
55
56fn map_keyring_error(e: keyring::Error) -> SecretError {
63 match e {
64 keyring::Error::NoEntry => SecretError::NotFound,
65 keyring::Error::NoStorageAccess(inner) => SecretError::Unavailable {
66 backend: BACKEND,
67 source: inner,
68 },
69 other => SecretError::Backend {
70 backend: BACKEND,
71 source: other.into(),
72 },
73 }
74}
75
76fn map_join_error(e: tokio::task::JoinError) -> SecretError {
78 SecretError::Backend {
79 backend: BACKEND,
80 source: e.into(),
81 }
82}
83
84#[cfg(target_os = "linux")]
94fn require_persistent_keyring() -> Result<(), SecretError> {
95 use linux_keyutils::{KeyRing, KeyRingIdentifier};
96 KeyRing::get_persistent(KeyRingIdentifier::Session).map_err(|e| SecretError::Unavailable {
97 backend: BACKEND,
98 source: format!(
99 "persistent keyring unavailable (kernel CONFIG_PERSISTENT_KEYRINGS \
100 may be disabled, or this environment restricts keyctl): {e}"
101 )
102 .into(),
103 })?;
104 Ok(())
105}
106
107#[derive(Debug)]
124pub struct KeyringBackend {
125 service: Arc<str>,
126 account: Arc<str>,
127}
128
129impl KeyringBackend {
130 pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
140 Self::from_parsed_uri(&SecretUri::parse(uri)?)
141 }
142
143 pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
145 if parsed.backend() != BACKEND {
146 return Err(SecretError::InvalidUri(format!(
147 "expected backend `keyring`, got `{}`",
148 parsed.backend()
149 )));
150 }
151 let (service, account) = parsed.path().split_once('/').ok_or_else(|| {
154 SecretError::InvalidUri(
155 "keyring URI requires `secretx:keyring:<service>/<account>`".into(),
156 )
157 })?;
158 if service.is_empty() {
159 return Err(SecretError::InvalidUri(
160 "keyring URI: service name must not be empty".into(),
161 ));
162 }
163 if account.is_empty() {
164 return Err(SecretError::InvalidUri(
165 "keyring URI: account name must not be empty".into(),
166 ));
167 }
168 if parsed.param("field").is_some() {
172 return Err(SecretError::InvalidUri(
173 "keyring does not support ?field= (kernel keyring values are opaque strings, not JSON \
174 objects); remove ?field= or use a backend that supports JSON field extraction \
175 (e.g. aws-sm)"
176 .into(),
177 ));
178 }
179 Ok(Self {
180 service: Arc::from(service),
181 account: Arc::from(account),
182 })
183 }
184}
185
186#[async_trait::async_trait]
187impl SecretStore for KeyringBackend {
188 async fn get(&self) -> Result<SecretValue, SecretError> {
189 let service = self.service.clone();
190 let account = self.account.clone();
191 tokio::task::spawn_blocking(move || {
194 #[cfg(not(target_os = "linux"))]
195 return Err(SecretError::Unavailable {
196 backend: BACKEND,
197 source: "secretx-keyring requires Linux (kernel persistent keyring); \
198 not implemented on this platform"
199 .into(),
200 });
201 #[cfg(target_os = "linux")]
202 require_persistent_keyring()?;
203 let entry =
204 keyring::Entry::new(&service, &account).map_err(map_keyring_error)?;
205 entry
210 .get_password()
211 .map(|pw| SecretValue::new(pw.into_bytes()))
212 .map_err(map_keyring_error)
213 })
214 .await
215 .map_err(map_join_error)?
216 }
217
218 async fn refresh(&self) -> Result<SecretValue, SecretError> {
219 self.get().await
220 }
221}
222
223#[async_trait::async_trait]
224impl WritableSecretStore for KeyringBackend {
225 async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
226 let password = Zeroizing::new(
229 std::str::from_utf8(value.as_bytes())
230 .map_err(|_| {
231 SecretError::DecodeFailed("keyring backend requires UTF-8 secret values".into())
232 })?
233 .to_owned(),
234 );
235 let service = self.service.clone();
236 let account = self.account.clone();
237 tokio::task::spawn_blocking(move || {
238 #[cfg(not(target_os = "linux"))]
239 {
240 let _ = (&service, &account, &password);
241 return Err(SecretError::Unavailable {
242 backend: BACKEND,
243 source: "secretx-keyring requires Linux (kernel persistent keyring); \
244 not implemented on this platform"
245 .into(),
246 });
247 }
248 #[cfg(target_os = "linux")]
249 require_persistent_keyring()?;
250 let entry =
251 keyring::Entry::new(&service, &account).map_err(map_keyring_error)?;
252 entry.set_password(&password).map_err(map_keyring_error)
253 })
254 .await
255 .map_err(map_join_error)?
256 }
257}
258
259#[cfg(target_os = "linux")]
260inventory::submit!(secretx_core::BackendRegistration::new(
261 "keyring",
262 |uri: &secretx_core::SecretUri| {
263 let b = KeyringBackend::from_parsed_uri(uri)?;
264 Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
265 },
266));
267
268#[cfg(target_os = "linux")]
269inventory::submit!(secretx_core::WritableBackendRegistration::new(
270 "keyring",
271 |uri: &secretx_core::SecretUri| {
272 let b = KeyringBackend::from_parsed_uri(uri)?;
273 Ok(Arc::new(b) as Arc<dyn secretx_core::WritableSecretStore>)
274 },
275));
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 const _: () = {
282 const fn assert_send_sync<T: Send + Sync>() {}
283 assert_send_sync::<KeyringBackend>();
284 };
285
286 #[test]
289 fn from_uri_ok() {
290 let b = KeyringBackend::from_uri("secretx:keyring:my-app/api-key").unwrap();
291 assert_eq!(&*b.service, "my-app");
292 assert_eq!(&*b.account, "api-key");
293 }
294
295 #[test]
296 fn from_uri_ok_nested_account() {
297 let b = KeyringBackend::from_uri("secretx:keyring:svc/user/sub").unwrap();
299 assert_eq!(&*b.service, "svc");
300 assert_eq!(&*b.account, "user/sub");
301 }
302
303 #[test]
304 fn from_uri_empty_service() {
305 assert!(matches!(
306 KeyringBackend::from_uri("secretx:keyring:/account"),
307 Err(SecretError::InvalidUri(_))
308 ));
309 }
310
311 #[test]
312 fn from_uri_wrong_backend() {
313 assert!(matches!(
314 KeyringBackend::from_uri("secretx:env:MY_VAR"),
315 Err(SecretError::InvalidUri(_))
316 ));
317 }
318
319 #[test]
320 fn from_uri_missing_slash() {
321 assert!(matches!(
323 KeyringBackend::from_uri("secretx:keyring:onlyone"),
324 Err(SecretError::InvalidUri(_))
325 ));
326 }
327
328 #[test]
329 fn from_uri_empty_account() {
330 assert!(matches!(
332 KeyringBackend::from_uri("secretx:keyring:svc/"),
333 Err(SecretError::InvalidUri(_))
334 ));
335 }
336
337 #[test]
338 fn from_uri_empty_path() {
339 assert!(matches!(
341 KeyringBackend::from_uri("secretx:keyring"),
342 Err(SecretError::InvalidUri(_))
343 ));
344 }
345
346 #[test]
347 fn from_uri_field_selector_rejected() {
348 let Err(SecretError::InvalidUri(msg)) =
351 KeyringBackend::from_uri("secretx:keyring:my-app/api-key?field=token")
352 else {
353 panic!("expected InvalidUri");
354 };
355 assert!(
356 msg.contains("keyring does not support ?field="),
357 "error must mention the limitation, got: {msg}"
358 );
359 }
360
361 fn is_kernel_keyring_unavailable(e: &SecretError) -> bool {
369 matches!(e, SecretError::Unavailable { .. })
370 }
371
372 struct KeyringCleanup {
375 svc: &'static str,
376 acct: &'static str,
377 }
378 impl Drop for KeyringCleanup {
379 fn drop(&mut self) {
380 if let Ok(entry) = keyring::Entry::new(self.svc, self.acct) {
381 let _ = entry.delete_credential();
382 }
383 }
384 }
385
386 #[tokio::test]
387 async fn integration_roundtrip() {
388 if std::env::var("SECRETX_KEYRING_INTEGRATION_TESTS").as_deref() != Ok("1") {
389 eprintln!("skipped: set SECRETX_KEYRING_INTEGRATION_TESTS=1 to run");
390 return;
391 }
392
393 let svc = "secretx-test";
394 let acct = "roundtrip";
395 let uri = format!("secretx:keyring:{svc}/{acct}");
396
397 let backend = KeyringBackend::from_uri(&uri).unwrap();
398
399 let _cleanup = KeyringCleanup { svc, acct };
402 if let Ok(entry) = keyring::Entry::new(svc, acct) {
403 let _ = entry.delete_credential();
404 }
405
406 let put_result = backend
408 .put(SecretValue::new(b"test-secret-value".to_vec()))
409 .await;
410 match put_result {
411 Ok(()) => {}
412 Err(ref e) if is_kernel_keyring_unavailable(e) => {
413 eprintln!("keyring: kernel keyring unavailable, skipping integration test");
414 return;
415 }
416 Err(e) => panic!("put failed: {e}"),
417 }
418
419 let got = backend.get().await.expect("get after put failed");
421 assert_eq!(got.as_bytes(), b"test-secret-value");
422
423 let refreshed = backend.refresh().await.expect("refresh failed");
425 assert_eq!(refreshed.as_bytes(), b"test-secret-value");
426
427 drop(_cleanup);
429 let after = backend.get().await;
430 assert!(
431 matches!(after, Err(SecretError::NotFound)),
432 "expected NotFound after delete"
433 );
434 }
435
436 #[tokio::test]
438 async fn integration_empty_secret_rejected() {
439 if std::env::var("SECRETX_KEYRING_INTEGRATION_TESTS").as_deref() != Ok("1") {
440 eprintln!("skipped: set SECRETX_KEYRING_INTEGRATION_TESTS=1 to run");
441 return;
442 }
443 let backend =
444 KeyringBackend::from_uri("secretx:keyring:secretx-test/empty-reject").unwrap();
445 let result = backend.put(SecretValue::new(Vec::new())).await;
446 assert!(
447 result.is_err(),
448 "empty secret should be rejected, got Ok"
449 );
450 }
451}