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: keyring_core::Entry,
}
impl KeyringStore {
#[cfg(not(windows))]
pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
Ok(Self {
entry: Entry::new(service, account)?.inner,
})
}
#[cfg(windows)]
pub fn new(service: &str, account: &str) -> Result<Self, KeyStoreError> {
use keyring_core::api::CredentialStoreApi;
let local = std::collections::HashMap::from([("persistence", "local")]);
let store = windows_native_keyring_store::Store::new()?;
let entry = store.build(service, account, Some(&local))?;
Ok(Self { entry })
}
}
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_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()),
}
}
}
#[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(windows)]
#[test]
fn a_node_key_round_trips_through_credential_manager_and_a_key_file_is_refused() {
use crate::node_key::{KeyFileError, NodeKey, Purpose};
use crate::profile::Profile;
for profile in [Profile::PqPure, Profile::PqHybrid] {
let account = format!("keystore-round-trip-{}", profile.name());
let store =
KeyringStore::new("macula-rust-test", &account).expect("Credential Manager");
let key = NodeKey::generate_identity(profile, 0).expect("a key");
let result = key
.save_to_keystore(&store)
.and_then(|()| NodeKey::load_from_keystore(&store, Purpose::Identity, profile));
let persistence = store
.entry
.get_attributes()
.map(|a| a.get("persistence").cloned());
store.delete_key().expect("cleanup delete should succeed");
assert_eq!(
persistence.ok().flatten().as_deref(),
Some("Local"),
"{profile:?}"
);
let loaded = result.expect("save/load round trip through Credential Manager");
assert_eq!(loaded.public_key(), key.public_key(), "{profile:?}");
}
let path = std::env::temp_dir().join("macula-rust-test-key");
let _ = std::fs::remove_file(&path);
let refused = NodeKey::load_or_create(&path, Profile::PqPure);
assert!(matches!(refused, Err(KeyFileError::NoKeyFile)));
assert!(!path.exists(), "no key file written");
}
#[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");
}
}