use keyring::Entry;
use macula_mldsa::Zeroizing;
pub trait KeyStore {
fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError>;
fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError>;
fn delete_key(&self) -> Result<(), KeyStoreError>;
}
pub struct KeyringStore {
entry: Entry,
}
impl KeyringStore {
pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
Ok(Self {
entry: Entry::new(service, account)?,
})
}
}
impl KeyStore for KeyringStore {
fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> {
self.entry.set_secret(key)?;
Ok(())
}
fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError> {
match self.entry.get_secret() {
Ok(secret) => Ok(Zeroizing::new(secret)),
Err(keyring::Error::NoEntry) => Err(KeyStoreError::NotFound),
Err(e) => Err(e.into()),
}
}
fn delete_key(&self) -> Result<(), KeyStoreError> {
match self.entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
}
#[cfg(target_os = "linux")]
pub struct LinuxKeyutilsStore {
entry: keyring_core::Entry,
}
#[cfg(target_os = "linux")]
impl LinuxKeyutilsStore {
pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
use keyring_core::api::CredentialStoreApi;
let store = linux_keyutils_keyring_store::Store::new()?;
let entry = store.build(service, account, None)?;
Ok(Self { entry })
}
}
#[cfg(target_os = "linux")]
impl KeyStore for LinuxKeyutilsStore {
fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> {
self.entry.set_secret(key)?;
Ok(())
}
fn load_key(&self) -> Result<Zeroizing<Vec<u8>>, KeyStoreError> {
match self.entry.get_secret() {
Ok(secret) => Ok(Zeroizing::new(secret)),
Err(keyring_core::Error::NoEntry) => Err(KeyStoreError::NotFound),
Err(e) => Err(e.into()),
}
}
fn delete_key(&self) -> Result<(), KeyStoreError> {
match self.entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
}
#[derive(Debug)]
pub enum KeyStoreError {
NotFound,
Backend(keyring::Error),
}
impl std::fmt::Display for KeyStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyStoreError::NotFound => write!(f, "no key stored under this identity"),
KeyStoreError::Backend(e) => write!(f, "platform secure store error: {e}"),
}
}
}
impl std::error::Error for KeyStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
KeyStoreError::Backend(e) => Some(e),
_ => None,
}
}
}
impl From<keyring::Error> for KeyStoreError {
fn from(e: keyring::Error) -> Self {
match e {
keyring::Error::NoEntry => KeyStoreError::NotFound,
other => KeyStoreError::Backend(other),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
static KEYRING_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(target_os = "linux")]
fn test_store() -> LinuxKeyutilsStore {
LinuxKeyutilsStore::new("macula-rust-test", "keystore-round-trip-test-entry")
.expect("Store::new/build should succeed -- keyutils is always available on Linux")
}
#[cfg(target_os = "linux")]
#[test]
fn save_then_load_returns_the_same_key() {
let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let store = test_store();
let key = vec![0x42u8; 2400];
let result = (|| -> Result<(), KeyStoreError> {
store.save_key(&key)?;
let loaded = store.load_key()?;
assert_eq!(*loaded, key);
Ok(())
})();
store.delete_key().expect("cleanup delete should succeed");
result.expect("save/load round trip should succeed");
}
#[cfg(target_os = "linux")]
#[test]
fn load_before_any_save_reports_not_found() {
let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let store = test_store();
let _ = store.delete_key();
assert!(matches!(store.load_key(), Err(KeyStoreError::NotFound)));
}
#[cfg(target_os = "linux")]
#[test]
fn delete_is_idempotent() {
let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let store = test_store();
store.save_key(&[0x7Fu8; 32]).expect("save");
store.delete_key().expect("first delete");
store
.delete_key()
.expect("second delete on an absent entry");
}
}