Files
IronStorage/crates/storage/tests/authentication_leases.rs
2026-08-10 02:18:57 +00:00

359 lines
11 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>>;
#[derive(Default)]
struct BackendState {
values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>,
retrieves: usize,
locks: usize,
unlocks: usize,
}
#[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 retrieves(&self) -> usize {
self.0.lock().expect("test mutex").retrieves
}
fn locks(&self) -> usize {
self.0.lock().expect("test mutex").locks
}
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
match state.fault.take() {
Some(error) => Err(error),
None => Ok(()),
}
}
}
impl SecretStoreBackend for MemoryBackend {
fn create(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists);
}
state
.values
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
Ok(())
}
fn retrieve(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.retrieves += 1;
state
.values
.get(locator)
.map(|value| SecretBytes::new(value.expose().to_vec()))
.ok_or(SecretStoreError::Missing)
}
fn replace(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
let existing = state
.values
.get_mut(locator)
.ok_or(SecretStoreError::Missing)?;
*existing = SecretBytes::new(value.to_vec());
Ok(())
}
fn delete(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state
.values
.remove(locator)
.map(drop)
.ok_or(SecretStoreError::Missing)
}
fn lock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.locks += 1;
Ok(())
}
fn unlock(&self) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?;
state.unlocks += 1;
Ok(())
}
}
#[derive(Clone, Default)]
struct ManualClock(Arc<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 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(())
}