Files
IronStorage/crates/storage/tests/secret_store.rs
2026-08-10 00:23:40 +00:00

352 lines
11 KiB
Rust

#![forbid(unsafe_code)]
mod support;
use std::{
collections::BTreeMap,
error::Error,
num::NonZeroUsize,
sync::{Arc, Mutex},
time::Duration,
};
use ironstorage::{
config::ConfigLoader,
crypto::KeyStore,
git::{GitCredentialProvider as _, GitError},
repository::{EncryptedEntry, SecretBytes},
secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
},
};
use support::compatibility::FixtureSet;
type TestResult = Result<(), Box<dyn Error>>;
#[derive(Default)]
struct MemoryState {
values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>,
retrieves: usize,
protections: Vec<SecretProtection>,
}
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<MemoryState>>);
impl MemoryBackend {
fn fail_next(&self, error: SecretStoreError) {
self.0.lock().expect("test mutex").fault = Some(error);
}
fn corrupt_first(&self) {
let mut state = self.0.lock().expect("test mutex");
let value = state.values.values_mut().next().expect("stored test value");
*value = SecretBytes::new(b"not an IronStorage record".to_vec());
}
fn retrieves(&self) -> usize {
self.0.lock().expect("test mutex").retrieves
}
fn protections(&self) -> Vec<SecretProtection> {
self.0.lock().expect("test mutex").protections.clone()
}
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
match state.fault.take() {
Some(error) => Err(error),
None => Ok(()),
}
}
}
impl SecretStoreBackend for MemoryBackend {
fn create(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.protections.push(protection);
if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists);
}
state
.values
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
Ok(())
}
fn retrieve(
&self,
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.retrieves += 1;
state.protections.push(protection);
state
.values
.get(locator)
.map(|value| SecretBytes::new(value.expose().to_vec()))
.ok_or(SecretStoreError::Missing)
}
fn replace(
&self,
locator: &SecretLocator,
protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.protections.push(protection);
let existing = state
.values
.get_mut(locator)
.ok_or(SecretStoreError::Missing)?;
*existing = SecretBytes::new(value.to_vec());
Ok(())
}
fn delete(
&self,
locator: &SecretLocator,
protection: SecretProtection,
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.protections.push(protection);
state
.values
.remove(locator)
.map(drop)
.ok_or(SecretStoreError::Missing)
}
fn lock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)
}
fn unlock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)
}
}
fn store(backend: MemoryBackend) -> SecretStore<MemoryBackend> {
SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
}
fn fingerprint_reference() -> SecretReference {
SecretReference::openpgp_passphrase("0123456789ABCDEF0123456789ABCDEF01234567")
.expect("valid fingerprint")
}
#[test]
fn lifecycle_is_explicit_and_create_never_silently_replaces() -> TestResult {
let backend = MemoryBackend::default();
let store = store(backend);
let reference = fingerprint_reference();
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Locked)
));
store.unlock()?;
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Missing)
));
store.create(&reference, SecretBytes::new(b"first".to_vec()))?;
assert_eq!(store.retrieve(&reference)?.expose(), b"first");
assert_eq!(
store.create(&reference, SecretBytes::new(b"other".to_vec())),
Err(SecretStoreError::AlreadyExists)
);
store.replace(&reference, SecretBytes::new(b"second".to_vec()))?;
assert_eq!(store.retrieve(&reference)?.expose(), b"second");
store.delete(&reference)?;
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Missing)
));
assert_eq!(store.delete(&reference), Err(SecretStoreError::Missing));
store.lock()?;
assert!(store.is_locked());
Ok(())
}
#[test]
fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestResult {
let backend = MemoryBackend::default();
backend.fail_next(SecretStoreError::Cancelled);
let store = store(backend.clone());
assert_eq!(store.unlock(), Err(SecretStoreError::Cancelled));
assert!(store.is_locked());
store.unlock()?;
let reference = SecretReference::https_git_credential(
"fixture-server",
"fixture-app",
"secret-account-name",
)?;
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Denied)
));
backend.fail_next(SecretStoreError::Unavailable);
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Unavailable)
));
store.create(&reference, SecretBytes::new(b"token".to_vec()))?;
backend.corrupt_first();
assert!(matches!(
store.retrieve(&reference),
Err(SecretStoreError::Corrupted)
));
let rendered = format!("{reference:?} {store:?} {}", SecretStoreError::Corrupted);
assert!(!rendered.contains("secret-account-name"));
assert!(!rendered.contains("not an IronStorage record"));
Ok(())
}
#[test]
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
let backend = MemoryBackend::default();
let policy = SecretCachePolicy::timed(
Duration::from_secs(60),
NonZeroUsize::new(1).expect("non-zero"),
)?;
let store = SecretStore::new(
backend.clone(),
policy,
SecretProtectionPolicy::device_unlocked(),
);
store.unlock()?;
let alice = SecretReference::https_git_credential("server", "application", "alice")?;
store.create(&alice, SecretBytes::new(b"token".to_vec()))?;
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
assert_eq!(
backend.retrieves(),
0,
"create populated the explicit cache"
);
let bob = SecretReference::https_git_credential("server", "application", "bob")?;
assert!(matches!(
store.retrieve(&bob),
Err(SecretStoreError::Missing)
));
assert_eq!(
store.replace(&bob, SecretBytes::new(b"other".to_vec())),
Err(SecretStoreError::Missing)
);
assert_eq!(store.delete(&bob), Err(SecretStoreError::Missing));
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
let passphrase = fingerprint_reference();
store.create(&passphrase, SecretBytes::new(b"passphrase".to_vec()))?;
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
assert_eq!(backend.retrieves(), 3, "capacity evicted the older record");
store.lock()?;
store.unlock()?;
assert_eq!(store.retrieve(&alice)?.expose(), b"token");
assert_eq!(backend.retrieves(), 4, "lock discarded cached bytes");
assert!(
SecretCachePolicy::timed(
Duration::from_secs(16 * 60),
NonZeroUsize::new(1).expect("non-zero")
)
.is_err()
);
Ok(())
}
#[test]
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
let fixture = FixtureSet::load()?;
let key = fixture.key("alice")?;
let keys = KeyStore::load(fixture.path("keys"))?;
let backend = MemoryBackend::default();
let mut store = SecretStore::new(
backend.clone(),
SecretCachePolicy::Disabled,
SecretProtectionPolicy::user_presence_for_openpgp(),
);
store.unlock()?;
let passphrase = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?;
store.create(
&passphrase,
SecretBytes::new(key.passphrase.as_bytes().to_vec()),
)?;
let entry = fixture
.generated
.entries
.iter()
.find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg")
.expect("compatibility entry exists");
assert_eq!(
keys.decrypt(
&EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?),
&mut store,
)?
.expose(),
fixture.read("expected/basic/email/personal.txt")?
);
let temporary = tempfile::tempdir()?;
fs_config(&temporary)?;
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
.load(Some(&temporary.path().join("config.toml")))?;
let remote = &config.git_remotes()[0];
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.credential(remote.server_id(), remote.application_id()),
Err(GitError::CredentialCancelled)
));
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.credential(remote.server_id(), remote.application_id()),
Err(GitError::CredentialAccessDenied)
));
let git = SecretReference::https_git_credential(
remote.server_id().as_str(),
remote.application_id().as_str(),
"alice",
)?;
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
let credential = store.credential(remote.server_id(), remote.application_id())?;
assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"https-token");
assert!(
backend
.protections()
.contains(&SecretProtection::RequireUserPresence)
);
Ok(())
}
fn fs_config(temporary: &tempfile::TempDir) -> TestResult {
std::fs::create_dir_all(temporary.path().join("keys"))?;
std::fs::create_dir_all(temporary.path().join("vault"))?;
std::fs::create_dir_all(temporary.path().join("native"))?;
std::fs::write(
temporary.path().join("config.toml"),
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'server'\napplication_id = 'application'\n",
)?;
Ok(())
}