#![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>; #[derive(Default)] struct MemoryState { values: BTreeMap, fault: Option, retrieves: usize, protections: Vec, } #[derive(Clone, Default)] struct MemoryBackend(Arc>); 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 { 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 { 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 { 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(()) }