use crate::backend::{BackendKey, KeychainBackend};
use crate::error::{KeystoreError, Result};
use parking_lot::Mutex;
use zeroize::Zeroizing;
const INDEX_ACCOUNT: &str = "__dig_keystore_index__";
trait RawStore: Send + Sync + 'static {
fn get(&self, account: &str) -> Result<Option<Vec<u8>>>;
fn set(&self, account: &str, secret: &[u8]) -> Result<()>;
fn remove(&self, account: &str) -> Result<()>;
}
pub struct OsKeychainBackend {
store: Box<dyn RawStore>,
index_lock: Mutex<()>,
}
impl OsKeychainBackend {
#[cfg(any(test, target_os = "windows", target_os = "macos"))]
fn with_store(store: Box<dyn RawStore>) -> Self {
Self {
store,
index_lock: Mutex::new(()),
}
}
fn load_index(&self) -> Vec<String> {
self.load_index_for_update().unwrap_or_default()
}
fn load_index_for_update(&self) -> Result<Vec<String>> {
match self.store.get(INDEX_ACCOUNT) {
Ok(Some(bytes)) => {
let raw = Zeroizing::new(bytes);
Ok(String::from_utf8_lossy(&raw)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
Ok(None) => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn store_index(&self, keys: &[String]) {
let joined = Zeroizing::new(keys.join("\n").into_bytes());
let _ = self.store.set(INDEX_ACCOUNT, &joined);
}
fn index_insert(&self, key: &str) {
let _guard = self.index_lock.lock();
let Ok(mut keys) = self.load_index_for_update() else {
return;
};
if !keys.iter().any(|k| k == key) {
keys.push(key.to_owned());
self.store_index(&keys);
}
}
fn index_remove(&self, key: &str) {
let _guard = self.index_lock.lock();
let Ok(mut keys) = self.load_index_for_update() else {
return;
};
let before = keys.len();
keys.retain(|k| k != key);
if keys.len() != before {
self.store_index(&keys);
}
}
}
fn validate_key_name(name: &str) -> Result<()> {
if name == INDEX_ACCOUNT || name.contains('\n') {
return Err(KeystoreError::from(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid key name (reserved or contains newline): {name:?}"),
)));
}
Ok(())
}
impl std::fmt::Debug for OsKeychainBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OsKeychainBackend")
.field("store", &"<redacted>")
.finish()
}
}
impl KeychainBackend for OsKeychainBackend {
fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
match self.store.get(key.as_str())? {
Some(bytes) => Ok(bytes),
None => Err(KeystoreError::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("key not found: {key}"),
))),
}
}
fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
validate_key_name(key.as_str())?;
self.store.set(key.as_str(), data)?;
self.index_insert(key.as_str());
Ok(())
}
fn delete(&self, key: &BackendKey) -> Result<()> {
self.store.remove(key.as_str())?;
self.index_remove(key.as_str());
Ok(())
}
fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
Ok(self
.load_index()
.into_iter()
.filter(|k| k.starts_with(prefix))
.map(BackendKey::new)
.collect())
}
fn exists(&self, key: &BackendKey) -> Result<bool> {
Ok(self.store.get(key.as_str())?.is_some())
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
struct KeyringStore {
service: String,
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
impl KeyringStore {
fn entry(&self, account: &str) -> keyring::Result<keyring::Entry> {
keyring::Entry::new(&self.service, account)
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn keyring_err(e: keyring::Error) -> KeystoreError {
KeystoreError::from(std::io::Error::other(format!("OS credential store: {e}")))
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
impl RawStore for KeyringStore {
fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
match self.entry(account).and_then(|e| e.get_secret()) {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(keyring_err(e)),
}
}
fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
self.entry(account)
.and_then(|e| e.set_secret(secret))
.map_err(keyring_err)
}
fn remove(&self, account: &str) -> Result<()> {
match self.entry(account).and_then(|e| e.delete_credential()) {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(keyring_err(e)),
}
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
impl OsKeychainBackend {
pub fn open(service: impl Into<String>) -> Option<Self> {
let store = KeyringStore {
service: service.into(),
};
let probe = format!("__dig_keystore_probe__{}", std::process::id());
match store.get(&probe) {
Ok(_) => Some(Self::with_store(Box::new(store))),
Err(_) => None,
}
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
impl OsKeychainBackend {
pub fn open(_service: impl Into<String>) -> Option<Self> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[derive(Default)]
struct FakeStore {
map: Mutex<HashMap<String, Vec<u8>>>,
fail: bool,
}
impl RawStore for FakeStore {
fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
if self.fail {
return Err(KeystoreError::from(std::io::Error::other("unreachable")));
}
Ok(self.map.lock().get(account).cloned())
}
fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
self.map.lock().insert(account.to_owned(), secret.to_vec());
Ok(())
}
fn remove(&self, account: &str) -> Result<()> {
self.map.lock().remove(account);
Ok(())
}
}
fn backend() -> OsKeychainBackend {
OsKeychainBackend::with_store(Box::<FakeStore>::default())
}
#[test]
fn write_then_read_roundtrip() {
let be = backend();
let key = BackendKey::new("identity");
let blob = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF];
be.write(&key, &blob).unwrap();
assert_eq!(be.read(&key).unwrap(), blob);
}
#[test]
fn read_absent_is_not_found() {
let be = backend();
let err = be.read(&BackendKey::new("missing")).unwrap_err();
match err {
KeystoreError::Backend(io) => {
assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected Backend(NotFound), got {other:?}"),
}
}
#[test]
fn write_overwrites_in_place() {
let be = backend();
let key = BackendKey::new("k");
be.write(&key, b"first").unwrap();
be.write(&key, b"second").unwrap();
assert_eq!(be.read(&key).unwrap(), b"second");
}
#[test]
fn delete_removes_and_is_idempotent() {
let be = backend();
let key = BackendKey::new("gone");
be.write(&key, b"bye").unwrap();
assert!(be.exists(&key).unwrap());
be.delete(&key).unwrap();
assert!(!be.exists(&key).unwrap());
be.delete(&key).unwrap(); }
#[test]
fn list_filters_by_prefix_and_hides_index() {
let be = backend();
be.write(&BackendKey::new("validator/a"), b"1").unwrap();
be.write(&BackendKey::new("validator/b"), b"2").unwrap();
be.write(&BackendKey::new("wallet/c"), b"3").unwrap();
let mut matched: Vec<String> = be
.list("validator/")
.unwrap()
.into_iter()
.map(|k| k.as_str().to_owned())
.collect();
matched.sort();
assert_eq!(matched, vec!["validator/a", "validator/b"]);
let all: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
assert_eq!(all.len(), 3);
assert!(!all.iter().any(|k| k == INDEX_ACCOUNT));
}
#[test]
fn delete_drops_key_from_list() {
let be = backend();
be.write(&BackendKey::new("a"), b"1").unwrap();
be.write(&BackendKey::new("b"), b"2").unwrap();
be.delete(&BackendKey::new("a")).unwrap();
let remaining: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
assert_eq!(remaining, vec!["b".to_owned()]);
}
#[test]
fn store_error_is_propagated_not_swallowed() {
let be = OsKeychainBackend::with_store(Box::new(FakeStore {
fail: true,
..Default::default()
}));
assert!(be.read(&BackendKey::new("x")).is_err());
assert!(be.exists(&BackendKey::new("x")).is_err());
}
#[test]
fn write_rejects_reserved_name_and_newline() {
let be = backend();
let err = be.write(&BackendKey::new(INDEX_ACCOUNT), b"x").unwrap_err();
assert!(matches!(err, KeystoreError::Backend(_)));
let err = be.write(&BackendKey::new("evil\nname"), b"x").unwrap_err();
assert!(matches!(err, KeystoreError::Backend(_)));
be.write(&BackendKey::new("validator_bls"), b"ok").unwrap();
assert_eq!(be.read(&BackendKey::new("validator_bls")).unwrap(), b"ok");
}
#[test]
fn transient_index_read_error_does_not_drop_existing_names() {
struct FlakyIndexStore {
map: Mutex<HashMap<String, Vec<u8>>>,
fail_index_read: std::sync::Arc<Mutex<bool>>,
}
impl RawStore for FlakyIndexStore {
fn get(&self, account: &str) -> Result<Option<Vec<u8>>> {
if account == INDEX_ACCOUNT && *self.fail_index_read.lock() {
return Err(KeystoreError::from(std::io::Error::other(
"transient keyring read failure",
)));
}
Ok(self.map.lock().get(account).cloned())
}
fn set(&self, account: &str, secret: &[u8]) -> Result<()> {
self.map.lock().insert(account.to_owned(), secret.to_vec());
Ok(())
}
fn remove(&self, account: &str) -> Result<()> {
self.map.lock().remove(account);
Ok(())
}
}
let fail_index_read = std::sync::Arc::new(Mutex::new(true));
let store = FlakyIndexStore {
map: Mutex::new(HashMap::from([
("a".to_owned(), b"1".to_vec()),
("b".to_owned(), b"2".to_vec()),
(INDEX_ACCOUNT.to_owned(), b"a\nb".to_vec()),
])),
fail_index_read: fail_index_read.clone(),
};
let be = OsKeychainBackend::with_store(Box::new(store));
be.write(&BackendKey::new("c"), b"3").unwrap();
assert!(be.exists(&BackendKey::new("c")).unwrap());
assert!(be.list("").unwrap().is_empty());
*fail_index_read.lock() = false;
let mut names: Vec<String> = be.list("").unwrap().into_iter().map(|k| k.0).collect();
names.sort();
assert_eq!(names, vec!["a".to_owned(), "b".to_owned()]);
}
#[test]
fn debug_is_redacted() {
let rendered = format!("{:?}", backend());
assert!(rendered.contains("<redacted>"));
}
}
#[cfg(all(test, any(target_os = "windows", target_os = "macos")))]
mod os_integration {
use super::*;
#[test]
fn real_os_store_round_trips_where_available() {
let service = format!("dig-keystore-test:{}", std::process::id());
let Some(be) = OsKeychainBackend::open(&service) else {
eprintln!(
"no OS credential store on this host — skipping (FakeStore covers the logic)"
);
return;
};
let key = BackendKey::new("identity");
assert!(!be.exists(&key).unwrap());
let blob = [0x01, 0x02, 0x03, 0xFE];
be.write(&key, &blob).unwrap();
assert!(be.exists(&key).unwrap());
assert_eq!(be.read(&key).unwrap(), blob);
be.write(&key, b"v2").unwrap();
assert_eq!(be.read(&key).unwrap(), b"v2");
assert!(be
.list("")
.unwrap()
.iter()
.any(|k| k.as_str() == "identity"));
be.delete(&key).unwrap();
assert!(!be.exists(&key).unwrap());
be.delete(&key).unwrap();
let _ = be.store.remove(INDEX_ACCOUNT);
}
}