Skip to main content

secretx_keyring/
lib.rs

1//! Linux kernel keyring backend for secretx.
2//!
3//! # Integration test status
4//!
5//! Unit tests (URI parsing, error mapping) pass without credentials.
6//! The integration test (`SECRETX_KEYRING_INTEGRATION_TESTS=1`) uses the Linux
7//! kernel keyring directly (no daemon required). Run with:
8//!
9//! ```sh
10//! SECRETX_KEYRING_INTEGRATION_TESTS=1 cargo test -p secretx-keyring
11//! ```
12//!
13//! On Linux, secrets are stored via the kernel
14//! [persistent keyring](https://www.man7.org/linux/man-pages/man7/persistent-keyring.7.html),
15//! which survives across logout/login sessions for a configurable window
16//! (default: a few days, set by `/proc/sys/kernel/keys/persistent_keyring_expiry`).
17//! Secrets do **not** survive reboots.
18//!
19//! See [`keyutils(7)`](https://www.man7.org/linux/man-pages/man7/keyutils.7.html)
20//! for the kernel keyutils subsystem overview.
21//!
22//! **Persistent keyring probe**: `get` and `put` probe `keyctl_get_persistent`
23//! before each operation. If the kernel does not support
24//! `CONFIG_PERSISTENT_KEYRINGS` (e.g. some containers, restricted namespaces,
25//! or hardened kernels), [`SecretError::Unavailable`] is returned rather than
26//! silently falling back to the session keyring (which expires when the process
27//! exits).
28//!
29//! **Integration-tested 2026-04-28**: Linux headless (kernel persistent keyring,
30//! no daemon).
31//!
32//! URI: `secretx:keyring:<service>/<account>`
33//!
34//! ```rust,no_run
35//! # async fn example() -> Result<(), secretx_core::SecretError> {
36//! use secretx_keyring::KeyringBackend;
37//! use secretx_core::{SecretStore, SecretValue, WritableSecretStore};
38//!
39//! // Read
40//! let store = KeyringBackend::from_uri("secretx:keyring:my-app/api-key")?;
41//! let value = store.get().await?;
42//!
43//! // Write (requires WritableSecretStore in scope)
44//! store.put(SecretValue::new(b"new-secret".to_vec())).await?;
45//! # Ok(())
46//! # }
47//! ```
48
49use std::sync::Arc;
50
51use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue, WritableSecretStore};
52use zeroize::Zeroizing;
53
54const BACKEND: &str = "keyring";
55
56/// Map a `keyring::Error` into the appropriate `SecretError` variant.
57///
58/// - `NoEntry` → `NotFound` (expected on `get`, should not occur on `put`).
59/// - `NoStorageAccess` → `Unavailable` (transient; the inner platform error
60///   is forwarded directly).
61/// - Everything else → `Backend` (permanent).
62fn 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
76/// Map a `tokio::task::JoinError` into `SecretError::Backend`.
77fn map_join_error(e: tokio::task::JoinError) -> SecretError {
78    SecretError::Backend {
79        backend: BACKEND,
80        source: e.into(),
81    }
82}
83
84/// Probe that the kernel persistent keyring is reachable.
85///
86/// Returns `Ok(())` if `keyctl_get_persistent` succeeds, or
87/// [`SecretError::Unavailable`] if the kernel does not support
88/// `CONFIG_PERSISTENT_KEYRINGS` (containers, restricted namespaces, etc.).
89///
90/// Without this check, the `keyring` crate silently falls back to the session
91/// keyring, which expires when the process exits — a much weaker durability
92/// guarantee than the persistent keyring's multi-day window.
93#[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/// Backend that reads and writes secrets via the Linux kernel keyring.
108///
109/// On Linux, `get` and `put` probe for persistent keyring availability and
110/// return [`SecretError::Unavailable`] if it is not present, rather than
111/// silently falling back to the session keyring.
112///
113/// The URI path encodes both a service name and an account name separated by
114/// the first `/`:
115///
116/// ```text
117/// secretx:keyring:<service>/<account>
118/// ```
119///
120/// `get` and `refresh` retrieve the stored password string.
121/// `put` writes a new password string; the value must be valid UTF-8 and
122/// non-empty (the kernel keyutils subsystem rejects empty secrets).
123#[derive(Debug)]
124pub struct KeyringBackend {
125    service: Arc<str>,
126    account: Arc<str>,
127}
128
129impl KeyringBackend {
130    /// Construct from a `secretx:keyring:<service>/<account>` URI.
131    ///
132    /// Does not open the keychain — construction only.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`SecretError::InvalidUri`] if the backend is not `keyring`,
137    /// the path is empty, or the path contains no `/` separator (both
138    /// `service` and `account` must be non-empty).
139    pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
140        Self::from_parsed_uri(&SecretUri::parse(uri)?)
141    }
142
143    /// Construct from a pre-parsed [`SecretUri`].
144    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        // path must be "<service>/<account>" — split on the first '/'.
152        // account may itself contain slashes (e.g. "svc/user/sub").
153        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        // Keyring values are opaque byte strings stored by the OS keychain;
169        // ?field= JSON extraction is not supported and would silently return
170        // the full stored value, which is confusing.  Reject early.
171        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        // Kernel keyring calls (keyctl syscalls) are synchronous.
192        // Run them on a blocking thread to avoid stalling the async executor.
193        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            // ZEROIZATION GAP: keyring crate returns plain String from the OS
206            // keychain.  `pw.into_bytes()` is zero-copy (reuses the same heap
207            // allocation), so the buffer enters Zeroizing immediately.  The
208            // keychain's own internal copy is outside our control.
209            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        // Decode to UTF-8 before entering spawn_blocking (no I/O needed here).
227        // Wrap in Zeroizing so the plaintext copy is zeroed when the closure returns.
228        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    // ── URI parsing tests (no OS keychain required) ───────────────────────────
287
288    #[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        // account portion may contain slashes; only the first '/' is the separator.
298        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        // path has no '/' so account is absent
322        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        // trailing slash means account is empty
331        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        // no path component at all
340        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        // Keyring values are opaque strings; ?field= is not supported and must
349        // be rejected at construction time.
350        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    // ── Integration tests (require OS keychain) ───────────────────────────────
362    //
363    // Gated behind SECRETX_KEYRING_INTEGRATION_TESTS=1.  No daemon required —
364    // uses the kernel persistent keyring directly.
365
366    /// Returns true if the kernel keyring is unavailable (e.g. running in a
367    /// container that restricts keyrings, or the keyutils subsystem is absent).
368    fn is_kernel_keyring_unavailable(e: &SecretError) -> bool {
369        matches!(e, SecretError::Unavailable { .. })
370    }
371
372    /// Drop guard that deletes a keyring entry on drop, ensuring cleanup
373    /// even if an assertion panics mid-test.
374    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        // Clean up any leftover entry from a previous run, and install a
400        // drop guard so cleanup happens even if assertions panic.
401        let _cleanup = KeyringCleanup { svc, acct };
402        if let Ok(entry) = keyring::Entry::new(svc, acct) {
403            let _ = entry.delete_credential();
404        }
405
406        // Write.
407        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        // Read back.
420        let got = backend.get().await.expect("get after put failed");
421        assert_eq!(got.as_bytes(), b"test-secret-value");
422
423        // Refresh should also work.
424        let refreshed = backend.refresh().await.expect("refresh failed");
425        assert_eq!(refreshed.as_bytes(), b"test-secret-value");
426
427        // Drop guard handles cleanup. Verify post-deletion state.
428        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    /// Empty secrets are rejected by the kernel keyutils subsystem.
437    #[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}