From f03fdc063cef22b323a207cb00b3ab9fe31aa839 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 02:18:57 +0000 Subject: [PATCH] Implement shared authentication leases --- README.md | 3 + crates/storage/src/authentication.rs | 529 ++++++++++++++++++ crates/storage/src/config.rs | 31 + crates/storage/src/crypto.rs | 9 + crates/storage/src/lib.rs | 1 + crates/storage/src/secret_store.rs | 6 +- crates/storage/tests/authentication_leases.rs | 358 ++++++++++++ crates/storage/tests/config_contract.rs | 44 +- docs/authentication-leases.md | 47 ++ docs/configuration.md | 10 + 10 files changed, 1035 insertions(+), 3 deletions(-) create mode 100644 crates/storage/src/authentication.rs create mode 100644 crates/storage/tests/authentication_leases.rs create mode 100644 docs/authentication-leases.md diff --git a/README.md b/README.md index 6552468..8da3e18 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). Native credential storage, opaque secret references, user-presence policy, and bounded caching are documented in [`docs/secure-secret-storage.md`](docs/secure-secret-storage.md). +Shared authentication generations, explicit user activity, inactivity expiry, +and relock cleanup are documented in +[`docs/authentication-leases.md`](docs/authentication-leases.md). Clipboard cleanup/race behavior and platform-neutral QR rendering are documented in [`docs/presentation.md`](docs/presentation.md). Pass-OTP URI compatibility, RFC code generation, and atomic HOTP counters are diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs new file mode 100644 index 0000000..75f1cd3 --- /dev/null +++ b/crates/storage/src/authentication.rs @@ -0,0 +1,529 @@ +//! Shared authentication leases for interactive frontends. +//! +//! A session owns its secret store, so callers cannot bypass a lease by retaining +//! an independently unlocked store. Frontends explicitly report real user input +//! with [`AuthenticationHandle::touch_user_activity`]; secret reads, Git work, +//! refreshes, timers, and repaints deliberately do not extend the deadline. + +use std::{ + collections::BTreeMap, + error::Error, + fmt, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use crate::{ + crypto::{KeyInfo, SecretProvider, SecretProviderError}, + git::{GitCredential, GitCredentialProvider, GitError}, + repository::SecretBytes, + secret_store::{ + NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference, + SecretStore, SecretStoreBackend, SecretStoreError, + }, +}; + +pub const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(120); +pub const MAX_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); + +/// A validated inactivity duration shared by every frontend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AuthenticationTimeout(Duration); + +impl AuthenticationTimeout { + pub fn new(duration: Duration) -> Result { + if duration.is_zero() || duration > MAX_AUTHENTICATION_TIMEOUT { + return Err(AuthenticationError::InvalidTimeout); + } + Ok(Self(duration)) + } + + pub const fn duration(self) -> Duration { + self.0 + } +} + +impl Default for AuthenticationTimeout { + fn default() -> Self { + Self(DEFAULT_AUTHENTICATION_TIMEOUT) + } +} + +/// Monotonic clock boundary used to make expiry deterministic in tests. +pub trait AuthenticationClock: Send + Sync + 'static { + /// Elapsed monotonic time from an arbitrary, stable origin. + fn now(&self) -> Duration; +} + +/// Process-local monotonic clock used by production sessions. +#[derive(Clone, Debug)] +pub struct SystemAuthenticationClock { + origin: Arc, +} + +impl Default for SystemAuthenticationClock { + fn default() -> Self { + Self { + origin: Arc::new(Instant::now()), + } + } +} + +impl AuthenticationClock for SystemAuthenticationClock { + fn now(&self) -> Duration { + self.origin.elapsed() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthenticationError { + InvalidTimeout, + Locked, + Expired, + Revoked, + Cancelled, + ClockOverflow, + SecretStore(SecretStoreError), +} + +impl fmt::Display for AuthenticationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTimeout => formatter.write_str("the authentication timeout is invalid"), + Self::Locked => formatter.write_str("authentication is required"), + Self::Expired => formatter.write_str("the authentication lease expired"), + Self::Revoked => formatter.write_str("the authentication lease was revoked"), + Self::Cancelled => formatter.write_str("authentication was cancelled"), + Self::ClockOverflow => formatter.write_str("the authentication deadline overflowed"), + Self::SecretStore(error) => write!(formatter, "authentication failed: {error}"), + } + } +} + +impl Error for AuthenticationError {} + +impl From for AuthenticationError { + fn from(error: SecretStoreError) -> Self { + match error { + SecretStoreError::Cancelled => Self::Cancelled, + error => Self::SecretStore(error), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RevocationReason { + Expired, + Manual, + Cancelled, + AuthenticationFailed, +} + +struct ActiveLease { + generation: u64, + deadline: Duration, + /// One zeroizing passphrase per key actually requested by this lease. + unlock_material: BTreeMap, +} + +struct LeaseState { + next_generation: u64, + active: Option, + last_revocation: Option<(u64, RevocationReason)>, +} + +struct Shared { + store: SecretStore, + timeout: AuthenticationTimeout, + clock: C, + /// Serializes access checks with relock, eliminating check-then-use races. + operation: Mutex<()>, + state: Mutex, +} + +impl Drop for Shared { + fn drop(&mut self) { + if let Ok(state) = self.state.get_mut() { + state.active = None; + } + let _ = self.store.lock(); + } +} + +/// Owner of an OS-backed secret store and its single shared authentication lease. +pub struct AuthenticationSession< + B: SecretStoreBackend, + C: AuthenticationClock = SystemAuthenticationClock, +> { + shared: Arc>, +} + +impl Clone for AuthenticationSession { + fn clone(&self) -> Self { + Self { + shared: Arc::clone(&self.shared), + } + } +} + +impl fmt::Debug for AuthenticationSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthenticationSession") + .field("timeout", &self.shared.timeout) + .field("unlock_material", &"[REDACTED]") + .finish() + } +} + +impl AuthenticationSession { + pub fn new( + backend: B, + protections: SecretProtectionPolicy, + timeout: AuthenticationTimeout, + ) -> Self { + Self::with_clock( + backend, + protections, + timeout, + SystemAuthenticationClock::default(), + ) + } +} + +impl AuthenticationSession { + pub fn with_clock( + backend: B, + protections: SecretProtectionPolicy, + timeout: AuthenticationTimeout, + clock: C, + ) -> Self { + Self { + shared: Arc::new(Shared { + // The lease is the only cache. This prevents a second lifetime + // policy in SecretStore from retaining duplicate records. + store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections), + timeout, + clock, + operation: Mutex::new(()), + state: Mutex::new(LeaseState { + next_generation: 0, + active: None, + last_revocation: None, + }), + }), + } + } + + /// Authenticate through the OS secret store and cache only this key's passphrase. + /// + /// Unprotected secret keys still receive a lease, but cache no unlock bytes. + pub fn authenticate( + &self, + key: &KeyInfo, + ) -> Result, AuthenticationError> { + if !key.has_secret() { + return Err(AuthenticationError::Locked); + } + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let now = self.shared.clock.now(); + let deadline = now + .checked_add(self.shared.timeout.duration()) + .ok_or(AuthenticationError::ClockOverflow)?; + + let (generation, was_active) = { + let mut state = self.shared.state()?; + match state.active.as_mut() { + Some(active) => { + active.deadline = deadline; + (active.generation, true) + } + None => { + state.next_generation = state.next_generation.wrapping_add(1); + let generation = state.next_generation; + state.active = Some(ActiveLease { + generation, + deadline, + unlock_material: BTreeMap::new(), + }); + (generation, false) + } + } + }; + + if !was_active && let Err(error) = self.shared.store.unlock() { + self.shared + .revoke_local(generation, RevocationReason::AuthenticationFailed)?; + return Err(error.into()); + } + + if key.requires_passphrase() + && let Err(error) = self.shared.cache_key_passphrase(generation, key) + { + self.shared + .revoke_local(generation, RevocationReason::AuthenticationFailed)?; + let _ = self.shared.store.lock(); + return Err(error); + } + + Ok(AuthenticationHandle { + shared: Arc::clone(&self.shared), + generation, + }) + } + + /// Remaining active time. Calling this from a timer never extends the lease. + pub fn remaining_time(&self) -> Result, AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let now = self.shared.clock.now(); + Ok(self + .shared + .state()? + .active + .as_ref() + .map(|active| active.deadline.saturating_sub(now))) + } + + /// Poll a frontend timer. Returns true only when this call performed expiry. + pub fn expire(&self) -> Result { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed() + } + + /// Revoke every outstanding handle immediately. + pub fn manual_lock(&self) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.revoke(RevocationReason::Manual) + } + + /// Cancel authentication and revoke any lease created by the pending interaction. + pub fn cancel(&self) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.revoke(RevocationReason::Cancelled) + } +} + +/// Generation-bound secret access. Clones are revoked together. +pub struct AuthenticationHandle< + B: SecretStoreBackend, + C: AuthenticationClock = SystemAuthenticationClock, +> { + shared: Arc>, + generation: u64, +} + +impl Clone for AuthenticationHandle { + fn clone(&self) -> Self { + Self { + shared: Arc::clone(&self.shared), + generation: self.generation, + } + } +} + +impl fmt::Debug for AuthenticationHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthenticationHandle") + .field("generation", &"[REDACTED]") + .field("unlock_material", &"[REDACTED]") + .finish() + } +} + +impl AuthenticationHandle { + pub fn ensure_active(&self) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + self.shared.with_active(self.generation, |_| ()) + } + + /// Extend the lease only for an input event the frontend classified as user activity. + pub fn touch_user_activity(&self) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let now = self.shared.clock.now(); + let deadline = now + .checked_add(self.shared.timeout.duration()) + .ok_or(AuthenticationError::ClockOverflow)?; + self.shared + .with_active(self.generation, |active| active.deadline = deadline) + } + + pub fn remaining_time(&self) -> Result { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let now = self.shared.clock.now(); + self.shared.with_active(self.generation, |active| { + active.deadline.saturating_sub(now) + }) + } +} + +impl SecretProvider for AuthenticationHandle { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.shared + .secret_for(self.generation, key) + .map_err(|error| match error { + AuthenticationError::Cancelled => SecretProviderError::Cancelled, + _ => SecretProviderError::Unavailable, + }) + } +} + +impl GitCredentialProvider + for AuthenticationHandle +{ + fn credential( + &self, + server: &crate::config::ServerId, + application: &crate::config::ApplicationId, + ) -> Result { + let _operation = self + .shared + .operation() + .map_err(|_| GitError::CredentialsUnavailable)?; + self.shared + .expire_if_needed() + .map_err(|_| GitError::CredentialsUnavailable)?; + self.shared + .with_active(self.generation, |_| ()) + .map_err(|_| GitError::CredentialsUnavailable)?; + self.shared.store.credential(server, application) + } +} + +impl Shared { + fn operation(&self) -> Result, AuthenticationError> { + self.operation + .lock() + .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) + } + + fn state(&self) -> Result, AuthenticationError> { + self.state + .lock() + .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) + } + + fn expire_if_needed(&self) -> Result { + let now = self.clock.now(); + let expired = self + .state()? + .active + .as_ref() + .is_some_and(|active| now >= active.deadline); + if !expired { + return Ok(false); + } + self.revoke(RevocationReason::Expired)?; + Ok(true) + } + + fn revoke(&self, reason: RevocationReason) -> Result<(), AuthenticationError> { + let generation = self + .state()? + .active + .as_ref() + .map(|active| active.generation); + if let Some(generation) = generation { + self.revoke_local(generation, reason)?; + } + self.store.lock().map_err(AuthenticationError::from) + } + + fn revoke_local( + &self, + generation: u64, + reason: RevocationReason, + ) -> Result<(), AuthenticationError> { + let mut state = self.state()?; + if state + .active + .as_ref() + .is_some_and(|active| active.generation == generation) + { + // Dropping the map here zeroizes every cached passphrase before return. + state.active = None; + state.last_revocation = Some((generation, reason)); + } + Ok(()) + } + + fn with_active( + &self, + generation: u64, + operation: impl FnOnce(&mut ActiveLease) -> T, + ) -> Result { + let mut state = self.state()?; + if let Some(active) = state + .active + .as_mut() + .filter(|active| active.generation == generation) + { + return Ok(operation(active)); + } + Err(match state.last_revocation { + Some((revoked, RevocationReason::Expired)) if revoked == generation => { + AuthenticationError::Expired + } + Some((revoked, RevocationReason::Cancelled)) if revoked == generation => { + AuthenticationError::Cancelled + } + Some((revoked, _)) if revoked == generation => AuthenticationError::Revoked, + _ if state.active.is_none() => AuthenticationError::Locked, + _ => AuthenticationError::Revoked, + }) + } + + fn cache_key_passphrase( + &self, + generation: u64, + key: &KeyInfo, + ) -> Result<(), AuthenticationError> { + let fingerprint = key.fingerprint().as_str().to_owned(); + if self.with_active(generation, |active| { + active.unlock_material.contains_key(&fingerprint) + })? { + return Ok(()); + } + let reference = SecretReference::openpgp_passphrase(&fingerprint)?; + let passphrase = self.store.retrieve(&reference)?; + self.with_active(generation, |active| { + active.unlock_material.insert(fingerprint, passphrase); + })?; + Ok(()) + } + + fn secret_for( + &self, + generation: u64, + key: &KeyInfo, + ) -> Result { + let _operation = self.operation()?; + self.expire_if_needed()?; + self.cache_key_passphrase(generation, key)?; + let fingerprint = key.fingerprint().as_str(); + self.with_active(generation, |active| { + active + .unlock_material + .get(fingerprint) + .map(|secret| SecretBytes::new(secret.expose().to_vec())) + })? + .ok_or(AuthenticationError::SecretStore(SecretStoreError::Missing)) + } +} + +pub type NativeAuthenticationSession = + AuthenticationSession; + +impl NativeAuthenticationSession { + pub fn system( + protections: SecretProtectionPolicy, + timeout: AuthenticationTimeout, + ) -> Result { + Ok(Self::new(NativeSecretBackend::new()?, protections, timeout)) + } +} diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 6744d67..4f90b39 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -12,6 +12,7 @@ use std::{ use serde::Deserialize; use url::Url; +use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}; use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; const APPLICATION_DIRECTORY: &str = "ironstorage"; @@ -27,6 +28,7 @@ pub struct Config { key_material: PathBuf, editor: Option, clipboard_timeout: ClipboardTimeout, + authentication_timeout: AuthenticationTimeout, git_remotes: Vec, } @@ -60,6 +62,11 @@ impl Config { self.clipboard_timeout } + /// Shared inactivity lease used by every interactive frontend. + pub fn authentication_timeout(&self) -> AuthenticationTimeout { + self.authentication_timeout + } + pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } @@ -410,9 +417,17 @@ struct RawConfig { editor: Option, clipboard_timeout_seconds: Option, #[serde(default)] + security: RawSecurity, + #[serde(default)] git: RawGit, } +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSecurity { + inactivity_timeout_seconds: Option, +} + #[derive(Deserialize)] #[serde(untagged)] enum RawEditor { @@ -473,6 +488,14 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result Result Result<(), Confi "key_material", "editor", "clipboard_timeout_seconds", + "security", "git", ], )?; + if let Some(security) = root.get("security") { + let security = security.as_table().ok_or_else(|| ConfigError::Malformed { + path: source.to_owned(), + })?; + validate_table(security, "security", &["inactivity_timeout_seconds"])?; + } let Some(git) = root.get("git") else { return Ok(()); }; diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index 3014b67..ebe5369 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -56,6 +56,7 @@ pub struct KeyInfo { key_id: String, user_ids: Vec, has_secret: bool, + requires_passphrase: bool, can_encrypt: bool, can_sign: bool, } @@ -77,6 +78,10 @@ impl KeyInfo { self.has_secret } + pub fn requires_passphrase(&self) -> bool { + self.requires_passphrase + } + pub fn can_encrypt(&self) -> bool { self.can_encrypt } @@ -663,6 +668,10 @@ fn key_info(material: &KeyMaterial) -> KeyInfo { .filter_map(|user| user.id.as_str().map(str::to_owned)) .collect(), has_secret: material.secret.is_some(), + requires_passphrase: material + .secret + .as_ref() + .is_some_and(secret_requires_password), can_encrypt: encryption_target(&material.public).is_some(), can_sign: can_sign(&material.public), } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index f37bc8f..c88b0cc 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -5,6 +5,7 @@ //! //! This crate is the sole owner of stored and derived password-store objects. +pub mod authentication; pub mod command; pub mod config; pub mod crypto; diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index c80f3f2..1e82678 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -18,6 +18,8 @@ use crate::{ mod platform; +pub(crate) use platform::NativeSecretBackend; + const RECORD_MAGIC: &[u8] = b"IRONSTORAGE-SECRET\0"; const RECORD_VERSION: u8 = 1; const MAX_SECRET_BYTES: usize = 1024; @@ -542,7 +544,7 @@ impl SecretStore { } } -pub type NativeSecretStore = SecretStore; +pub type NativeSecretStore = SecretStore; impl NativeSecretStore { pub fn system( @@ -550,7 +552,7 @@ impl NativeSecretStore { protections: SecretProtectionPolicy, ) -> Result { Ok(Self::new( - platform::NativeSecretBackend::new()?, + NativeSecretBackend::new()?, cache_policy, protections, )) diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs new file mode 100644 index 0000000..3f843e5 --- /dev/null +++ b/crates/storage/tests/authentication_leases.rs @@ -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 = Result>; + +#[derive(Default)] +struct BackendState { + values: BTreeMap, + fault: Option, + retrieves: usize, + locks: usize, + unlocks: usize, +} + +#[derive(Clone, Default)] +struct MemoryBackend(Arc>); + +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 { + 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>); + +impl ManualClock { + fn advance(&self, duration: Duration) { + let mut now = self.0.lock().expect("test clock"); + *now += duration; + } +} + +impl AuthenticationClock for ManualClock { + fn now(&self) -> Duration { + *self.0.lock().expect("test clock") + } +} + +fn fixture_key(fixture: &FixtureSet, keys: &KeyStore, name: &str) -> TestResult { + let fingerprint = &fixture.key(name)?.primary_fingerprint; + keys.infos() + .find(|key| key.fingerprint().as_str() == fingerprint) + .ok_or_else(|| format!("missing imported fixture key {name}").into()) +} + +fn provision_passphrase(backend: MemoryBackend, key: &KeyInfo, passphrase: &[u8]) -> TestResult { + let store = SecretStore::new( + backend, + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock()?; + store.create( + &SecretReference::openpgp_passphrase(key.fingerprint().as_str())?, + SecretBytes::new(passphrase.to_vec()), + )?; + store.lock()?; + Ok(()) +} + +fn session( + backend: MemoryBackend, + clock: ManualClock, + seconds: u64, +) -> Result, AuthenticationError> { + Ok(AuthenticationSession::with_clock( + backend, + SecretProtectionPolicy::device_unlocked(), + AuthenticationTimeout::new(Duration::from_secs(seconds))?, + clock, + )) +} + +#[test] +fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not() -> TestResult { + let fixture = FixtureSet::load()?; + let keys = KeyStore::load(fixture.path("keys"))?; + let alice = fixture_key(&fixture, &keys, "alice")?; + let backend = MemoryBackend::default(); + provision_passphrase( + backend.clone(), + &alice, + fixture.key("alice")?.passphrase.as_bytes(), + )?; + let baseline_locks = backend.locks(); + let clock = ManualClock::default(); + let session = session(backend.clone(), clock.clone(), 120)?; + let mut handle = session.authenticate(&alice)?; + + assert_eq!(handle.remaining_time()?, Duration::from_secs(120)); + assert_eq!(backend.retrieves(), 1); + clock.advance(Duration::from_secs(100)); + assert_eq!(session.remaining_time()?, Some(Duration::from_secs(20))); + assert_eq!( + handle + .secret_for(&alice) + .expect("active lease supplies cached passphrase") + .expose(), + b"fixture-alice-passphrase" + ); + assert_eq!( + backend.retrieves(), + 1, + "the lease owns the only memory cache" + ); + clock.advance(Duration::from_secs(20)); + assert!(session.expire()?); + assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired)); + assert_eq!(session.remaining_time()?, None); + assert_eq!(backend.locks(), baseline_locks + 1); + + let handle = session.authenticate(&alice)?; + clock.advance(Duration::from_secs(100)); + handle.touch_user_activity()?; + clock.advance(Duration::from_secs(119)); + assert_eq!(handle.remaining_time()?, Duration::from_secs(1)); + clock.advance(Duration::from_secs(1)); + assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired)); + Ok(()) +} + +#[test] +fn 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(()) +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 00256e0..ade8105 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -2,8 +2,11 @@ use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration}; -use ironstorage::config::{ConfigError, ConfigLoader, EditorSource}; use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; +use ironstorage::{ + authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, + config::{ConfigError, ConfigLoader, EditorSource}, +}; use tempfile::TempDir; type TestResult = Result<(), Box>; @@ -83,6 +86,45 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { config.clipboard_timeout().duration(), DEFAULT_CLIPBOARD_TIMEOUT ); + assert_eq!( + config.authentication_timeout().duration(), + DEFAULT_AUTHENTICATION_TIMEOUT + ); + Ok(()) +} + +#[test] +fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit(&fixture.valid_contents().replace( + "editor = [\"code\", \"--wait\"]", + "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300", + ))?; + let config = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + assert_eq!( + config.authentication_timeout().duration(), + Duration::from_secs(300) + ); + + for timeout in [0, MAX_AUTHENTICATION_TIMEOUT.as_secs() + 1] { + fixture.write_explicit(&fixture.valid_contents().replace( + "editor = [\"code\", \"--wait\"]", + &format!( + "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = {timeout}" + ), + ))?; + assert_eq!( + fixture + .loader() + .load(Some(Path::new("config/config.toml"))) + .expect_err("invalid authentication timeout"), + ConfigError::InvalidField { + field: "security.inactivity_timeout_seconds" + } + ); + } Ok(()) } diff --git a/docs/authentication-leases.md b/docs/authentication-leases.md new file mode 100644 index 0000000..4549bfa --- /dev/null +++ b/docs/authentication-leases.md @@ -0,0 +1,47 @@ +# Authentication leases + +`crates/storage` owns the authentication and inactivity policy shared by every +interactive frontend. `AuthenticationSession` owns an uncached `SecretStore`, +and `authenticate` accepts only non-secret `KeyInfo`. A frontend therefore +never receives or retains a GPG passphrase itself. + +An authenticated session returns an `AuthenticationHandle`. Every clone is +bound to the same generation. Manual lock, cancellation, or expiry revokes the +generation, clears its cached unlock material, locks the backing secret store, +and makes all old handles reject later secret access. A new authentication +creates a distinct generation, so an old editor or view cannot become valid +again accidentally. + +The lease caches only a zeroizing `SecretBytes` passphrase for each protected +key actually requested during the active generation. The underlying +`SecretStore` cache is disabled for the session, avoiding duplicate cache +lifetimes. Relock drops this map before returning. Git credentials remain in +the OS-backed store and are retrieved only through a currently valid handle. + +## Activity and expiry + +The shared TOML setting is: + +```toml +[security] +inactivity_timeout_seconds = 120 +``` + +The default is 120 seconds; valid values range from 1 second through 24 hours. +Frontends call `touch_user_activity` only for real keyboard, pointer, touch, or +other intentional user input. Reading a secret, polling `remaining_time` or +`expire`, refreshing repository state, performing Git work, and repainting do +not move the deadline. This keeps presentation adapters from inventing their +own activity heuristics or timeout arithmetic. + +All access checks and relock transitions share one operation lock. At the exact +deadline, either an operation finishes before relock or expiry wins and the +operation observes a revoked handle; there is no check-then-use window into the +secret store. Frontend timers may call `expire` to eagerly clean up at the +deadline, while every handle operation also checks expiry before accessing a +secret. The monotonic clock boundary is injectable so these cases remain fully +deterministic in tests. + +OS authentication cancellation is reported distinctly. Explicit `cancel` +also revokes an active generation, which lets an abandoned authentication UI +clean up through the same storage-owned path as manual lock. diff --git a/docs/configuration.md b/docs/configuration.md index 246f816..b72d718 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,10 @@ editor = ["code", "--wait"] # Optional; upstream pass defaults to 45 seconds. Values are limited to 1..300. clipboard_timeout_seconds = 45 +# Optional; interactive frontends default to a two-minute inactivity lease. +[security] +inactivity_timeout_seconds = 120 + [[git.remotes]] name = "origin" url = "https://git.example.test/alice/password-store.git" @@ -58,6 +62,12 @@ It defaults to 45 seconds for upstream `pass` compatibility and must be between selection and every platform can restore or clear the value reliably without a background helper process. +`security.inactivity_timeout_seconds` controls the shared authentication lease +used by interactive frontends. It defaults to 120 seconds and accepts values +from 1 second through 24 hours. The storage crate owns deadline calculation and +relock; frontends report only genuine input events as user activity. Repaints, +timers, background refresh, and Git work never extend the lease. + Passwords, passphrases, tokens, credentials, private keys, and other secret values are forbidden in TOML. Unknown fields are rejected. Parse errors never echo the source line or value, so an accidentally supplied secret is not