Files
IronStorage/crates/storage/tests/authentication_leases.rs
T
hugo 43f660ace3
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s
Implement complete Android settings and recovery controls (#104)
2026-10-10 12:07:28 +02:00

1261 lines
43 KiB
Rust

#![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<T = ()> = Result<T, Box<dyn Error>>;
#[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 repository = Repository::open(vault.path())?;
ironstorage::git::GitRepository::init(
&repository,
ironstorage::git::GitIdentity::new("Original", "original@example.test")?,
)?;
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"
);
let original = std::fs::read(&source)?;
for value in ["not a number", "-1", "0", "86401", "18446744073709551616"] {
assert!(
authentication
.set_authentication_timeout_text(value)
.is_err()
);
assert_eq!(std::fs::read(&source)?, original);
}
assert!(
authentication
.set_git_identity("valid".to_owned(), "invalid\nemail".to_owned())
.is_err()
);
assert_eq!(std::fs::read(&source)?, original);
authentication.set_authentication_timeout_text(" 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();
let identity = authentication.set_git_identity(
"Android settings author".to_owned(),
"android-settings@example.test".to_owned(),
)?;
authentication.unlock_entry(
"email/personal",
Some(SecretBytes::new(
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
)),
)?;
let editor = authentication.begin_entry_editor("email/personal")?;
let id = editor.page().fields()[0].id();
authentication.discard_entry_editor(editor.id())?;
authentication.replace_entry_field(
"email/personal",
id,
"public-settings-password".to_owned(),
)?;
let git = ironstorage::git::GitRepository::open(&repository, identity)?;
let commits = git.log(Some(1))?;
assert_eq!(commits[0].author_name(), "Android settings author");
assert_eq!(commits[0].author_email(), "android-settings@example.test");
let store = SecretStore::new(
backend.clone(),
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
);
store.unlock()?;
let configured = Config::load(Some(&source))?;
let (server, application) = configured.git_remotes()[0].https_credentials().unwrap();
store.store_https_git_credential(
server,
application,
"public-settings-account",
SecretBytes::new(b"public-settings-token".to_vec()),
)?;
store.lock()?;
assert_eq!(
authentication
.preferences(false, false, false)?
.application_account(),
Some("public-settings-account")
);
authentication.remove_application_token()?;
assert_eq!(
authentication
.preferences(false, false, false)?
.application_account(),
None
);
authentication.remove_application_token()?;
drop(authentication);
assert!(
backend.locks() > baseline_locks,
"final owner locks the OS backend"
);
Ok(())
}
#[derive(Default)]
struct BackendState {
values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>,
protected_fault: Option<SecretStoreError>,
retrieves: usize,
locks: usize,
unlocks: usize,
last_protection: Option<SecretProtection>,
}
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BackendState>>);
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<SecretProtection> {
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<SecretBytes, SecretStoreError> {
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<Mutex<Duration>>);
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<KeyInfo> {
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<AuthenticationSession<MemoryBackend, ManualClock>, 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<MobileAuthentication> {
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<bool>, 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<SecretBytes, SecretStoreError> {
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::<Vec<_>>();
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::<Vec<_>>()
};
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::<Vec<_>>(),
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<u8>,
}
impl ironstorage::crypto::SecretProvider for FixtureSecret {
fn secret_for(
&mut self,
key: &KeyInfo,
) -> Result<SecretBytes, ironstorage::crypto::SecretProviderError> {
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(())
}