#![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()); for event in [ MobileLifecycleEvent::DeviceLocked, MobileLifecycleEvent::MemoryPressure, MobileLifecycleEvent::ProcessRecreated, ] { 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(5)); assert_eq!( authentication .lifecycle(MobileLifecycleEvent::Foreground)? .remaining_seconds(), 25 ); assert!(!authentication.lifecycle(event)?.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 presented_field_ids_reject_replaced_documents_and_ticks_do_not_renew_the_lease() -> 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 clock = ManualClock::default(); let authentication = MobileAuthentication::with_platform( Config::load(Some(&source))?, Arc::new(MemoryBackend::default()), 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 page = authentication.entry_page("email/personal", 59)?; let id = page.page().sections()[0].fields()[0].id(); assert!( !authentication .reveal_presented_entry_field("email/personal", id, page.page().revision())? .is_empty() ); clock.advance(Duration::from_secs(1)); authentication.refresh_entry_presentation("email/personal", page.page().revision(), 60)?; assert_eq!(authentication.state()?.remaining_seconds(), 1); authentication.replace_entry_field("email/personal", id, "changed password".to_owned())?; let error = authentication .replace_presented_entry_field( "email/personal", id, page.page().revision(), "must not overwrite".to_owned(), ) .unwrap_err(); assert_eq!(error.kind(), MobileAuthenticationErrorKind::Conflict); assert!(!authentication.state()?.unlocked()); authentication.unlock_entry( "email/personal", Some(SecretBytes::new( fixture.key("alice")?.passphrase.as_bytes().to_vec(), )), )?; assert_eq!( authentication.reveal_entry_field("email/personal", id)?, "changed password" ); clock.advance(Duration::from_secs(2)); assert!( authentication .refresh_entry_presentation("email/personal", page.page().revision(), 61) .is_err() ); assert!(!authentication.state()?.unlocked()); 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(()) } #[test] fn encrypted_editor_checkpoint_retains_inputs_ids_and_conflict_base_across_lock() -> TestResult { use ironstorage::{ config::Config, mobile_authentication::MobileAuthentication, mobile_entry::{MobileEntryEditorFieldKind, MobileEntryEditorInput}, 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", vault.path().to_str().unwrap(), fixture.path("keys").to_str().unwrap(), fixture.key("alice")?.primary_fingerprint, ), )?; let clock = ManualClock::default(); let backend = MemoryBackend::default(); let make = || { MobileAuthentication::with_platform( Config::load(Some(&source)).unwrap(), Arc::new(backend.clone()), Arc::new(clock.clone()), ) }; let authentication = make()?; let password = || SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec()); authentication.create_directory("", "checkpoint")?; let session = authentication.begin_create_entry("checkpoint", "entry", Some(password()))?; let id = session.id(); for (kind, name, value) in [ ( MobileEntryEditorFieldKind::Field, Some("custom".to_owned()), "first", ), ( MobileEntryEditorFieldKind::Field, Some("custom".to_owned()), "second", ), ( MobileEntryEditorFieldKind::Field, Some("pin".to_owned()), "123493", ), ( MobileEntryEditorFieldKind::Note, None, "first note\nsecond note", ), ( MobileEntryEditorFieldKind::OtpUri, None, "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", ), ] { authentication.add_entry_editor_field(id, kind, name, value.to_owned())?; } let page = authentication.entry_editor(id)?; let ids = page .fields() .iter() .map(|field| field.id()) .collect::>(); let inputs = |text: &str| { page.fields() .iter() .map(|field| { MobileEntryEditorInput::new( field.id(), field.name().map(str::to_owned), if field.kind() == MobileEntryEditorFieldKind::Password { Some(text.to_owned()) } else { field.value().map(str::to_owned) }, ) }) .collect::>() }; authentication.checkpoint_entry_editor(id, 1)?; authentication.checkpoint_entry_editor_inputs(id, 3, inputs("latest draft"))?; authentication.checkpoint_entry_editor_inputs(id, 2, inputs("stale input"))?; authentication.lifecycle(MobileLifecycleEvent::Background)?; assert!(!authentication.state()?.unlocked()); assert!(authentication.entry_editor(id).is_err()); // A queued input may finish after the cover/lease revocation; it only encrypts. authentication.checkpoint_entry_editor_inputs(id, 4, inputs("background-safe draft"))?; assert!( authentication .resume_entry_editor(id, Some(SecretBytes::new(b"incorrect".to_vec()))) .is_err() ); let recovered = authentication.resume_entry_editor(id, Some(password()))?; assert_eq!(recovered.session.id(), id); assert_eq!( recovered .session .page() .fields() .iter() .map(|field| field.id()) .collect::>(), ids ); let recovered_inputs = recovered.inputs.unwrap(); assert_eq!( recovered_inputs .first() .unwrap() .clone() .into_parts() .2 .as_deref(), Some("background-safe draft") ); // Validation failures leave both the document and encrypted checkpoint available. let invalid = page .fields() .iter() .map(|field| { MobileEntryEditorInput::new( field.id(), if field.name_editable() { Some("bad\nname".to_owned()) } else { field.name().map(str::to_owned) }, field.value().map(str::to_owned), ) }) .collect(); authentication.checkpoint_entry_editor_inputs(id, 5, invalid)?; let invalid = authentication .resume_entry_editor(id, None)? .inputs .unwrap(); assert!(authentication.save_entry_editor(id, invalid).is_err()); assert!(authentication.entry_editor(id).is_ok()); authentication.save_entry_editor(id, recovered_inputs)?; assert!( authentication.resume_entry_editor(id, None).is_err(), "successful save deletes the checkpoint" ); let opened = authentication.begin_entry_editor("checkpoint/entry")?; authentication.checkpoint_entry_editor(opened.id(), 6)?; let other = make()?; other.unlock_entry("checkpoint/entry", Some(password()))?; other.replace_entry_field("checkpoint/entry", ids[0], "concurrent change".to_owned())?; authentication.manual_lock()?; let recovered = authentication.resume_entry_editor(opened.id(), Some(password()))?; let fields = recovered .session .page() .fields() .iter() .map(|field| { MobileEntryEditorInput::new( field.id(), field.name().map(str::to_owned), field.value().map(str::to_owned), ) }) .collect(); assert!( authentication .save_entry_editor(opened.id(), fields) .is_err(), "checkpoint must preserve its original conflict base" ); authentication.discard_entry_editor(opened.id())?; assert!( authentication .resume_entry_editor(opened.id(), None) .is_err() ); let opened = authentication.begin_entry_editor("checkpoint/entry")?; authentication.checkpoint_entry_editor(opened.id(), 7)?; authentication.set_authentication_timeout(1)?; clock.advance(Duration::from_secs(2)); assert!(!authentication.state()?.unlocked()); assert!( authentication .resume_entry_editor(opened.id(), Some(password())) .is_ok() ); authentication.lifecycle(MobileLifecycleEvent::ProcessRecreated)?; assert!(!authentication.state()?.unlocked()); assert!( authentication .resume_entry_editor(opened.id(), Some(password())) .is_err() ); let repository = Repository::open(vault.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let path = ironstorage::repository::EntryPath::parse("checkpoint/binary")?; let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys) .resolve_for_entry(&path, None)?; let bytes = b"opaque password\nunknown: \xff\nPIN: 123493\n"; let encrypted = keys.encrypt(SecretBytes::new(bytes.to_vec()), recipients.recipients())?; repository.write_entry(&path, &encrypted)?; authentication.unlock_entry("checkpoint/binary", Some(password()))?; let editor = authentication.begin_entry_editor("checkpoint/binary")?; let opaque = editor .page() .fields() .iter() .find(|field| field.value().is_none()) .unwrap(); assert!(!opaque.name_editable()); authentication.checkpoint_entry_editor(editor.id(), 8)?; authentication.manual_lock()?; let recovered = authentication.resume_entry_editor(editor.id(), Some(password()))?; let fields = recovered .session .page() .fields() .iter() .map(|field| { MobileEntryEditorInput::new( field.id(), field.name().map(str::to_owned), if field.name() == Some("PIN") { Some("123494".to_owned()) } else { field.value().map(str::to_owned) }, ) }) .collect(); authentication.save_entry_editor(editor.id(), fields)?; struct FixtureSecret { fingerprint: String, password: Vec, } impl ironstorage::crypto::SecretProvider for FixtureSecret { fn secret_for( &mut self, key: &KeyInfo, ) -> Result { if key.fingerprint().as_str() != self.fingerprint { return Err(ironstorage::crypto::SecretProviderError::Unavailable); } Ok(SecretBytes::new(self.password.clone())) } } let decrypted = keys.decrypt( &repository.read_entry(&path)?, &mut FixtureSecret { fingerprint: fixture.key("alice")?.primary_fingerprint.clone(), password: fixture.key("alice")?.passphrase.as_bytes().to_vec(), }, )?; assert_eq!( decrypted.expose(), b"opaque password\nunknown: \xff\nPIN: 123494\n", "opaque non-UTF8 fields must round-trip losslessly" ); Ok(()) }