Implement shared authentication leases
This commit is contained in:
358
crates/storage/tests/authentication_leases.rs
Normal file
358
crates/storage/tests/authentication_leases.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
#![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(())
|
||||
}
|
||||
Reference in New Issue
Block a user