#![forbid(unsafe_code)] mod support; use std::{ collections::BTreeMap, error::Error, sync::{Arc, Mutex}, time::Duration, }; use ironstorage::{ authentication::{ AuthenticationClock, AuthenticationError, AuthenticationSession, AuthenticationTimeout, MAX_AUTHENTICATION_TIMEOUT, }, crypto::{KeyInfo, KeyStore, SecretProvider as _}, read::{ShowResult, VaultReader}, repository::{Repository, SecretBytes}, secret_store::{ SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, }, }; use support::compatibility::FixtureSet; type TestResult = Result>; #[derive(Default)] struct BackendState { values: BTreeMap, fault: Option, retrieves: usize, locks: usize, unlocks: usize, } #[derive(Clone, Default)] struct MemoryBackend(Arc>); impl MemoryBackend { fn fail_next(&self, error: SecretStoreError) { self.0.lock().expect("test mutex").fault = Some(error); } fn retrieves(&self) -> usize { self.0.lock().expect("test mutex").retrieves } fn locks(&self) -> usize { self.0.lock().expect("test mutex").locks } fn take_fault(state: &mut BackendState) -> 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)?; 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 .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)?; 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 .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)?; state.locks += 1; Ok(()) } fn unlock(&self) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; state.unlocks += 1; Ok(()) } } #[derive(Clone, Default)] struct ManualClock(Arc>); impl ManualClock { fn advance(&self, duration: Duration) { let mut now = self.0.lock().expect("test clock"); *now += duration; } } impl AuthenticationClock for ManualClock { fn now(&self) -> Duration { *self.0.lock().expect("test clock") } } fn fixture_key(fixture: &FixtureSet, keys: &KeyStore, name: &str) -> TestResult { let fingerprint = &fixture.key(name)?.primary_fingerprint; keys.infos() .find(|key| key.fingerprint().as_str() == fingerprint) .ok_or_else(|| format!("missing imported fixture key {name}").into()) } fn provision_passphrase(backend: MemoryBackend, key: &KeyInfo, passphrase: &[u8]) -> TestResult { let store = SecretStore::new( backend, SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ); store.unlock()?; store.create( &SecretReference::openpgp_passphrase(key.fingerprint().as_str())?, SecretBytes::new(passphrase.to_vec()), )?; store.lock()?; Ok(()) } fn session( backend: MemoryBackend, clock: ManualClock, seconds: u64, ) -> Result, AuthenticationError> { Ok(AuthenticationSession::with_clock( backend, SecretProtectionPolicy::device_unlocked(), AuthenticationTimeout::new(Duration::from_secs(seconds))?, clock, )) } #[test] fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture_key(&fixture, &keys, "alice")?; let backend = MemoryBackend::default(); provision_passphrase( backend.clone(), &alice, fixture.key("alice")?.passphrase.as_bytes(), )?; let baseline_locks = backend.locks(); let clock = ManualClock::default(); let session = session(backend.clone(), clock.clone(), 120)?; let mut handle = session.authenticate(&alice)?; assert_eq!(handle.remaining_time()?, Duration::from_secs(120)); assert_eq!(backend.retrieves(), 1); clock.advance(Duration::from_secs(100)); assert_eq!(session.remaining_time()?, Some(Duration::from_secs(20))); assert_eq!( handle .secret_for(&alice) .expect("active lease supplies cached passphrase") .expose(), b"fixture-alice-passphrase" ); assert_eq!( backend.retrieves(), 1, "the lease owns the only memory cache" ); clock.advance(Duration::from_secs(20)); assert!(session.expire()?); assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired)); assert_eq!(session.remaining_time()?, None); assert_eq!(backend.locks(), baseline_locks + 1); let handle = session.authenticate(&alice)?; clock.advance(Duration::from_secs(100)); handle.touch_user_activity()?; clock.advance(Duration::from_secs(119)); assert_eq!(handle.remaining_time()?, Duration::from_secs(1)); clock.advance(Duration::from_secs(1)); assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired)); Ok(()) } #[test] fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult { let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture_key(&fixture, &keys, "alice")?; let backend = MemoryBackend::default(); provision_passphrase( backend.clone(), &alice, fixture.key("alice")?.passphrase.as_bytes(), )?; let clock = ManualClock::default(); let session = session(backend.clone(), clock.clone(), 10)?; let first = session.authenticate(&alice)?; let first_clone = first.clone(); session.manual_lock()?; assert_eq!(first.ensure_active(), Err(AuthenticationError::Revoked)); assert_eq!( first_clone.ensure_active(), Err(AuthenticationError::Revoked) ); let second = session.authenticate(&alice)?; session.cancel()?; assert_eq!(second.ensure_active(), Err(AuthenticationError::Cancelled)); backend.fail_next(SecretStoreError::Cancelled); assert!(matches!( session.authenticate(&alice), Err(AuthenticationError::Cancelled) )); assert_eq!(session.remaining_time()?, None); Ok(()) } #[test] fn relock_clears_unlock_material_and_old_handles_cannot_open_entries() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture_key(&fixture, &keys, "alice")?; let backend = MemoryBackend::default(); provision_passphrase( backend.clone(), &alice, fixture.key("alice")?.passphrase.as_bytes(), )?; let clock = ManualClock::default(); let session = session(backend.clone(), clock.clone(), 5)?; let reader = VaultReader::new(&repository, &keys); let mut old_handle = session.authenticate(&alice)?; match reader.show(Some("email/personal"), &mut old_handle)? { ShowResult::Entry(contents) => assert_eq!( contents.expose(), fixture.read("expected/basic/email/personal.txt")? ), ShowResult::Directory(_) => panic!("fixture entry resolved as a directory"), } assert_eq!(backend.retrieves(), 1); session.manual_lock()?; assert!( reader .show(Some("email/personal"), &mut old_handle) .is_err() ); let mut new_handle = session.authenticate(&alice)?; assert_eq!( backend.retrieves(), 2, "relock discarded cached passphrase bytes" ); assert!(matches!( reader.show(Some("email/personal"), &mut new_handle)?, ShowResult::Entry(_) )); let debug = format!("{session:?} {new_handle:?}"); assert!(!debug.contains("fixture-alice-passphrase")); Ok(()) } #[test] fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> TestResult { assert_eq!( AuthenticationTimeout::new(Duration::ZERO), Err(AuthenticationError::InvalidTimeout) ); assert_eq!( AuthenticationTimeout::new(MAX_AUTHENTICATION_TIMEOUT + Duration::from_secs(1)), Err(AuthenticationError::InvalidTimeout) ); let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture_key(&fixture, &keys, "alice")?; let backend = MemoryBackend::default(); provision_passphrase( backend.clone(), &alice, fixture.key("alice")?.passphrase.as_bytes(), )?; let clock = ManualClock::default(); let session = session(backend.clone(), clock.clone(), 1)?; let handle = session.authenticate(&alice)?; let baseline_locks = backend.locks(); clock.advance(Duration::from_secs(1)); let expiry_session = session.clone(); let expiry = std::thread::spawn(move || expiry_session.expire()); let access = std::thread::spawn(move || handle.ensure_active()); let expiry_result = expiry.join().expect("expiry thread")?; let access_result = access.join().expect("access thread"); assert!(matches!(access_result, Err(AuthenticationError::Expired))); assert!(expiry_result || session.remaining_time()?.is_none()); assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once"); Ok(()) }