#![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>; #[test] fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_configuration() -> TestResult { use ironstorage::{ config::Config, mobile_authentication::MobileAuthentication, mobile_platform::MobileLifecycleEvent, }; let fixture = FixtureSet::load()?; let vault = fixture.materialize_store("basic")?; let root = tempfile::tempdir()?; let source = root.path().join("config.toml"); std::fs::write( &source, format!( "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/team/passwords.git\"\nserver_id = \"server-example\"\napplication_id = \"repository-example\"\n", vault.path().to_str().expect("fixture UTF8"), fixture.path("keys").to_str().expect("fixture UTF8"), fixture.key("alice")?.primary_fingerprint, ), )?; let backend = MemoryBackend::default(); let clock = ManualClock::default(); let authentication = MobileAuthentication::with_platform( Config::load(Some(&source))?, Arc::new(backend.clone()), Arc::new(clock.clone()), )?; assert!( !authentication .lifecycle(MobileLifecycleEvent::Foreground)? .unlocked() ); assert_eq!( authentication .preferences(false, false, false)? .application_account(), None ); assert_eq!( backend.retrieves(), 1, "preferences must use the injected backend" ); authentication.set_authentication_timeout(30)?; authentication.unlock_entry( "email/personal", Some(SecretBytes::new( fixture.key("alice")?.passphrase.as_bytes().to_vec(), )), )?; assert!(authentication.state()?.unlocked()); clock.advance(Duration::from_secs(20)); assert_eq!(authentication.state()?.remaining_seconds(), 10); authentication.lifecycle(MobileLifecycleEvent::UserActivity)?; assert_eq!(authentication.state()?.remaining_seconds(), 30); let editor = authentication.begin_entry_editor("email/personal")?; authentication.lifecycle(MobileLifecycleEvent::Background)?; assert!(!authentication.state()?.unlocked()); assert!(authentication.entry_editor(editor.id()).is_err()); let baseline_locks = backend.locks(); drop(authentication); assert!( backend.locks() > baseline_locks, "final owner locks the OS backend" ); Ok(()) } #[derive(Default)] struct BackendState { values: BTreeMap, fault: Option, protected_fault: Option, retrieves: usize, locks: usize, unlocks: usize, last_protection: Option, } #[derive(Clone, Default)] struct MemoryBackend(Arc>); impl MemoryBackend { fn fail_next(&self, error: SecretStoreError) { self.0.lock().expect("test mutex").fault = Some(error); } fn fail_protected(&self, error: SecretStoreError) { self.0.lock().unwrap().protected_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 last_protection(&self) -> Option { self.0.lock().expect("test mutex").last_protection } fn invalidate_enrollment(&self) { self.0.lock().expect("test mutex").values.clear(); } 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 matches!(locator, SecretLocator::OpenPgpPassphrase { .. }) && let Some(error) = state.protected_fault.take() { return Err(error); } state.last_protection = Some(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.last_protection = Some(protection); 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)?; if matches!(locator, SecretLocator::OpenPgpPassphrase { .. }) && let Some(error) = state.protected_fault.take() { return Err(error); } state.last_protection = Some(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.last_protection = Some(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)?; 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 timeout_updates_apply_to_the_active_lease_and_later_activity() -> 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, clock.clone(), 120)?; let handle = session.authenticate(&alice)?; clock.advance(Duration::from_secs(20)); session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?; assert_eq!(handle.remaining_time()?, Duration::from_secs(30)); clock.advance(Duration::from_secs(10)); handle.touch_user_activity()?; assert_eq!(handle.remaining_time()?, Duration::from_secs(30)); 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(()) } #[test] fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease() -> 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(); let clock = ManualClock::default(); let session = AuthenticationSession::with_clock( backend.clone(), SecretProtectionPolicy::current_biometry_for_openpgp(), AuthenticationTimeout::new(Duration::from_secs(30))?, clock.clone(), ); let reader = VaultReader::new(&repository, &keys); assert!(matches!( session.authenticate(&alice), Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) )); let mut manual = session.authenticate_with_passphrase( &alice, SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()), )?; assert!(matches!( reader.show(Some("email/personal"), &mut manual)?, ShowResult::Entry(_) )); manual.persist_passphrase(&alice)?; assert_eq!( backend.last_protection(), Some(SecretProtection::BiometryCurrentSet) ); session.manual_lock()?; let mut biometric = session.authenticate(&alice)?; assert!(matches!( reader.show(Some("email/personal"), &mut biometric)?, ShowResult::Entry(_) )); clock.advance(Duration::from_secs(30)); assert!(session.expire()?); assert_eq!(biometric.ensure_active(), Err(AuthenticationError::Expired)); backend.invalidate_enrollment(); assert!(matches!( session.authenticate(&alice), Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) )); let mut recovered = session.authenticate_with_passphrase( &alice, SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()), )?; assert!(matches!( reader.show(Some("email/personal"), &mut recovered)?, ShowResult::Entry(_) )); recovered.persist_passphrase(&alice)?; session.delete_key_passphrase(&alice)?; session.manual_lock()?; assert!(matches!( session.authenticate(&alice), Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) )); Ok(()) } #[test] fn mobile_enrollment_proves_the_key_commits_only_after_protection_and_recovers() -> TestResult { use ironstorage::{ config::Config, mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind}, }; let fixture = FixtureSet::load()?; let vault = fixture.materialize_store("basic")?; let root = tempfile::tempdir()?; let source = root.path().join("config.toml"); std::fs::write( &source, format!( "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", vault.path().to_str().unwrap(), fixture.path("keys").to_str().unwrap(), fixture.key("alice")?.primary_fingerprint ), )?; let backend = MemoryBackend::default(); let clock = ManualClock::default(); let make = || -> TestResult { Ok(MobileAuthentication::with_platform( Config::load(Some(&source))?, Arc::new(backend.clone()), Arc::new(clock.clone()), )?) }; let authentication = make()?; let password = || SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec()); assert_eq!( authentication .enroll_biometric_unlock(SecretBytes::new(b"incorrect".to_vec())) .unwrap_err() .kind(), MobileAuthenticationErrorKind::InvalidPassphrase ); assert!(backend.0.lock().unwrap().values.is_empty()); for (fault, kind) in [ ( SecretStoreError::Cancelled, MobileAuthenticationErrorKind::Cancelled, ), ( SecretStoreError::UnsupportedProtection, MobileAuthenticationErrorKind::BiometryUnavailable, ), ] { backend.fail_protected(fault); assert_eq!( authentication .enroll_biometric_unlock(password()) .unwrap_err() .kind(), kind ); assert!(!authentication.state()?.unlocked()); assert!(!Config::load(Some(&source))?.biometric_unlock_enabled()); assert!(backend.0.lock().unwrap().values.is_empty()); } let state = authentication.enroll_biometric_unlock(password())?; assert!(state.unlocked() && state.biometric_unlock_enabled()); assert_eq!( backend.last_protection(), Some(SecretProtection::BiometryCurrentSet) ); authentication.manual_lock()?; assert!( authentication .unlock_entry("email/personal", None)? .unlocked() ); let reads = backend.retrieves(); authentication.unlock_entry("email/personal", None)?; assert_eq!( reads, backend.retrieves(), "an active shared lease needs no second prompt" ); authentication.manual_lock()?; backend.fail_protected(SecretStoreError::Cancelled); assert_eq!( authentication .unlock_entry("email/personal", Some(password())) .unwrap_err() .kind(), MobileAuthenticationErrorKind::Cancelled ); let cancelled = authentication.state()?; assert!(!cancelled.unlocked()); assert_eq!(cancelled.remaining_seconds(), 0); for (fault, kind) in [ ( SecretStoreError::Lockout, MobileAuthenticationErrorKind::BiometryUnavailable, ), ( SecretStoreError::Denied, MobileAuthenticationErrorKind::BiometryUnavailable, ), ( SecretStoreError::Cancelled, MobileAuthenticationErrorKind::Cancelled, ), ( SecretStoreError::Invalidated, MobileAuthenticationErrorKind::PassphraseRequired, ), ( SecretStoreError::DeviceKeyMissing, MobileAuthenticationErrorKind::PassphraseRequired, ), ] { backend.fail_next(fault); assert_eq!( authentication .unlock_entry("email/personal", None) .unwrap_err() .kind(), kind ); assert!(!authentication.state()?.unlocked()); } backend.invalidate_enrollment(); assert_eq!( authentication .unlock_entry("email/personal", None) .unwrap_err() .kind(), MobileAuthenticationErrorKind::PassphraseRequired ); assert!( authentication .enroll_biometric_unlock(password())? .unlocked() ); authentication.manual_lock()?; drop(authentication); let recreated = make()?; assert!( !recreated.state()?.unlocked(), "process recreation cannot restore a lease" ); assert!(recreated.unlock_entry("email/personal", None)?.unlocked()); recreated.set_biometric_unlock(false)?; assert!(!recreated.state()?.unlocked()); assert!(backend.0.lock().unwrap().values.is_empty()); assert!(!Config::load(Some(&source))?.biometric_unlock_enabled()); Ok(()) } #[test] fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult { use ironstorage::{config::Config, mobile_authentication::MobileAuthentication}; let fixture = FixtureSet::load()?; let vault = fixture.materialize_store("basic")?; let root = tempfile::tempdir()?; let source = root.path().join("config.toml"); std::fs::write( &source, format!( "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", vault.path().to_str().unwrap(), fixture.path("keys").to_str().unwrap(), fixture.key("alice")?.primary_fingerprint ), )?; let backend = MemoryBackend::default(); let clock = ManualClock::default(); let authentication = MobileAuthentication::with_platform( Config::load(Some(&source))?, Arc::new(backend), Arc::new(clock.clone()), )?; authentication.set_authentication_timeout(2)?; authentication.unlock_entry( "email/personal", Some(SecretBytes::new( fixture.key("alice")?.passphrase.as_bytes().to_vec(), )), )?; let editor = authentication.begin_entry_editor("email/personal")?; clock.advance(Duration::from_secs(1)); assert_eq!(authentication.state()?.remaining_seconds(), 1); authentication.touch_user_activity()?; assert_eq!(authentication.state()?.remaining_seconds(), 2); clock.advance(Duration::from_secs(2)); assert!(!authentication.state()?.unlocked()); assert!(authentication.entry_editor(editor.id()).is_err()); assert!(authentication.entry_page("email/personal", 0).is_err()); assert!(authentication.touch_user_activity().is_err()); Ok(()) } #[test] fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult { struct PromptBackend { inner: MemoryBackend, started: std::sync::mpsc::Sender<()>, cancelled: (Mutex, std::sync::Condvar), } impl SecretStoreBackend for PromptBackend { fn create( &self, locator: &SecretLocator, protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.inner.create(locator, protection, value) } fn replace( &self, locator: &SecretLocator, protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.inner.replace(locator, protection, value) } fn delete( &self, locator: &SecretLocator, protection: SecretProtection, ) -> Result<(), SecretStoreError> { self.inner.delete(locator, protection) } fn retrieve( &self, _: &SecretLocator, _: SecretProtection, ) -> Result { self.started.send(()).unwrap(); let cancelled = self.cancelled.0.lock().unwrap(); let (cancelled, wait) = self .cancelled .1 .wait_timeout_while(cancelled, Duration::from_secs(3), |cancelled| !*cancelled) .unwrap(); assert!( !wait.timed_out() && *cancelled, "lock must interrupt the OS prompt" ); Err(SecretStoreError::Cancelled) } fn lock(&self) -> Result<(), SecretStoreError> { *self.cancelled.0.lock().unwrap() = true; self.cancelled.1.notify_all(); self.inner.lock() } fn unlock(&self) -> Result<(), SecretStoreError> { self.inner.unlock() } } let fixture = FixtureSet::load()?; let keys = KeyStore::load(fixture.path("keys"))?; let alice = fixture_key(&fixture, &keys, "alice")?; let (started, ready) = std::sync::mpsc::channel(); let backend = Arc::new(PromptBackend { inner: MemoryBackend::default(), started, cancelled: (Mutex::new(false), std::sync::Condvar::new()), }); let session = AuthenticationSession::with_clock( backend, SecretProtectionPolicy::current_biometry_for_openpgp(), AuthenticationTimeout::default(), ManualClock::default(), ); let worker_session = session.clone(); let worker = std::thread::spawn(move || worker_session.authenticate(&alice)); ready.recv_timeout(Duration::from_secs(2))?; session.manual_lock()?; assert!(matches!( worker.join().unwrap(), Err(AuthenticationError::Cancelled) )); assert_eq!(session.remaining_time()?, None); Ok(()) }