use std::sync::Arc;
use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue, WritableSecretStore};
use zeroize::Zeroizing;
const BACKEND: &str = "keyring";
fn map_keyring_error(e: keyring::Error) -> SecretError {
match e {
keyring::Error::NoEntry => SecretError::NotFound,
keyring::Error::NoStorageAccess(inner) => SecretError::Unavailable {
backend: BACKEND,
source: inner,
},
other => SecretError::Backend {
backend: BACKEND,
source: other.into(),
},
}
}
fn map_join_error(e: tokio::task::JoinError) -> SecretError {
SecretError::Backend {
backend: BACKEND,
source: e.into(),
}
}
#[cfg(target_os = "linux")]
fn require_persistent_keyring() -> Result<(), SecretError> {
use linux_keyutils::{KeyRing, KeyRingIdentifier};
KeyRing::get_persistent(KeyRingIdentifier::Session).map_err(|e| SecretError::Unavailable {
backend: BACKEND,
source: format!(
"persistent keyring unavailable (kernel CONFIG_PERSISTENT_KEYRINGS \
may be disabled, or this environment restricts keyctl): {e}"
)
.into(),
})?;
Ok(())
}
#[derive(Debug)]
pub struct KeyringBackend {
service: Arc<str>,
account: Arc<str>,
}
impl KeyringBackend {
pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
Self::from_parsed_uri(&SecretUri::parse(uri)?)
}
pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
if parsed.backend() != BACKEND {
return Err(SecretError::InvalidUri(format!(
"expected backend `keyring`, got `{}`",
parsed.backend()
)));
}
let (service, account) = parsed.path().split_once('/').ok_or_else(|| {
SecretError::InvalidUri(
"keyring URI requires `secretx:keyring:<service>/<account>`".into(),
)
})?;
if service.is_empty() {
return Err(SecretError::InvalidUri(
"keyring URI: service name must not be empty".into(),
));
}
if account.is_empty() {
return Err(SecretError::InvalidUri(
"keyring URI: account name must not be empty".into(),
));
}
if parsed.param("field").is_some() {
return Err(SecretError::InvalidUri(
"keyring does not support ?field= (kernel keyring values are opaque strings, not JSON \
objects); remove ?field= or use a backend that supports JSON field extraction \
(e.g. aws-sm)"
.into(),
));
}
Ok(Self {
service: Arc::from(service),
account: Arc::from(account),
})
}
}
#[async_trait::async_trait]
impl SecretStore for KeyringBackend {
async fn get(&self) -> Result<SecretValue, SecretError> {
let service = self.service.clone();
let account = self.account.clone();
tokio::task::spawn_blocking(move || {
#[cfg(not(target_os = "linux"))]
return Err(SecretError::Unavailable {
backend: BACKEND,
source: "secretx-keyring requires Linux (kernel persistent keyring); \
not implemented on this platform"
.into(),
});
#[cfg(target_os = "linux")]
require_persistent_keyring()?;
let entry =
keyring::Entry::new(&service, &account).map_err(map_keyring_error)?;
entry
.get_password()
.map(|pw| SecretValue::new(pw.into_bytes()))
.map_err(map_keyring_error)
})
.await
.map_err(map_join_error)?
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
self.get().await
}
}
#[async_trait::async_trait]
impl WritableSecretStore for KeyringBackend {
async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
let password = Zeroizing::new(
std::str::from_utf8(value.as_bytes())
.map_err(|_| {
SecretError::DecodeFailed("keyring backend requires UTF-8 secret values".into())
})?
.to_owned(),
);
let service = self.service.clone();
let account = self.account.clone();
tokio::task::spawn_blocking(move || {
#[cfg(not(target_os = "linux"))]
{
let _ = (&service, &account, &password);
return Err(SecretError::Unavailable {
backend: BACKEND,
source: "secretx-keyring requires Linux (kernel persistent keyring); \
not implemented on this platform"
.into(),
});
}
#[cfg(target_os = "linux")]
require_persistent_keyring()?;
let entry =
keyring::Entry::new(&service, &account).map_err(map_keyring_error)?;
entry.set_password(&password).map_err(map_keyring_error)
})
.await
.map_err(map_join_error)?
}
}
#[cfg(target_os = "linux")]
inventory::submit!(secretx_core::BackendRegistration::new(
"keyring",
|uri: &secretx_core::SecretUri| {
let b = KeyringBackend::from_parsed_uri(uri)?;
Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
},
));
#[cfg(target_os = "linux")]
inventory::submit!(secretx_core::WritableBackendRegistration::new(
"keyring",
|uri: &secretx_core::SecretUri| {
let b = KeyringBackend::from_parsed_uri(uri)?;
Ok(Arc::new(b) as Arc<dyn secretx_core::WritableSecretStore>)
},
));
#[cfg(test)]
mod tests {
use super::*;
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<KeyringBackend>();
};
#[test]
fn from_uri_ok() {
let b = KeyringBackend::from_uri("secretx:keyring:my-app/api-key").unwrap();
assert_eq!(&*b.service, "my-app");
assert_eq!(&*b.account, "api-key");
}
#[test]
fn from_uri_ok_nested_account() {
let b = KeyringBackend::from_uri("secretx:keyring:svc/user/sub").unwrap();
assert_eq!(&*b.service, "svc");
assert_eq!(&*b.account, "user/sub");
}
#[test]
fn from_uri_empty_service() {
assert!(matches!(
KeyringBackend::from_uri("secretx:keyring:/account"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_wrong_backend() {
assert!(matches!(
KeyringBackend::from_uri("secretx:env:MY_VAR"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_missing_slash() {
assert!(matches!(
KeyringBackend::from_uri("secretx:keyring:onlyone"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_empty_account() {
assert!(matches!(
KeyringBackend::from_uri("secretx:keyring:svc/"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_empty_path() {
assert!(matches!(
KeyringBackend::from_uri("secretx:keyring"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_field_selector_rejected() {
let Err(SecretError::InvalidUri(msg)) =
KeyringBackend::from_uri("secretx:keyring:my-app/api-key?field=token")
else {
panic!("expected InvalidUri");
};
assert!(
msg.contains("keyring does not support ?field="),
"error must mention the limitation, got: {msg}"
);
}
fn is_kernel_keyring_unavailable(e: &SecretError) -> bool {
matches!(e, SecretError::Unavailable { .. })
}
struct KeyringCleanup {
svc: &'static str,
acct: &'static str,
}
impl Drop for KeyringCleanup {
fn drop(&mut self) {
if let Ok(entry) = keyring::Entry::new(self.svc, self.acct) {
let _ = entry.delete_credential();
}
}
}
#[tokio::test]
async fn integration_roundtrip() {
if std::env::var("SECRETX_KEYRING_INTEGRATION_TESTS").as_deref() != Ok("1") {
eprintln!("skipped: set SECRETX_KEYRING_INTEGRATION_TESTS=1 to run");
return;
}
let svc = "secretx-test";
let acct = "roundtrip";
let uri = format!("secretx:keyring:{svc}/{acct}");
let backend = KeyringBackend::from_uri(&uri).unwrap();
let _cleanup = KeyringCleanup { svc, acct };
if let Ok(entry) = keyring::Entry::new(svc, acct) {
let _ = entry.delete_credential();
}
let put_result = backend
.put(SecretValue::new(b"test-secret-value".to_vec()))
.await;
match put_result {
Ok(()) => {}
Err(ref e) if is_kernel_keyring_unavailable(e) => {
eprintln!("keyring: kernel keyring unavailable, skipping integration test");
return;
}
Err(e) => panic!("put failed: {e}"),
}
let got = backend.get().await.expect("get after put failed");
assert_eq!(got.as_bytes(), b"test-secret-value");
let refreshed = backend.refresh().await.expect("refresh failed");
assert_eq!(refreshed.as_bytes(), b"test-secret-value");
drop(_cleanup);
let after = backend.get().await;
assert!(
matches!(after, Err(SecretError::NotFound)),
"expected NotFound after delete"
);
}
#[tokio::test]
async fn integration_empty_secret_rejected() {
if std::env::var("SECRETX_KEYRING_INTEGRATION_TESTS").as_deref() != Ok("1") {
eprintln!("skipped: set SECRETX_KEYRING_INTEGRATION_TESTS=1 to run");
return;
}
let backend =
KeyringBackend::from_uri("secretx:keyring:secretx-test/empty-reject").unwrap();
let result = backend.put(SecretValue::new(Vec::new())).await;
assert!(
result.is_err(),
"empty secret should be rejected, got Ok"
);
}
}