1190 lines
40 KiB
Rust
1190 lines
40 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 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<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(())
|
|
}
|