//! 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)) } }