530 lines
17 KiB
Rust
530 lines
17 KiB
Rust
//! 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<Self, AuthenticationError> {
|
|
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<Instant>,
|
|
}
|
|
|
|
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<SecretStoreError> 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<String, SecretBytes>,
|
|
}
|
|
|
|
struct LeaseState {
|
|
next_generation: u64,
|
|
active: Option<ActiveLease>,
|
|
last_revocation: Option<(u64, RevocationReason)>,
|
|
}
|
|
|
|
struct Shared<B: SecretStoreBackend, C: AuthenticationClock> {
|
|
store: SecretStore<B>,
|
|
timeout: AuthenticationTimeout,
|
|
clock: C,
|
|
/// Serializes access checks with relock, eliminating check-then-use races.
|
|
operation: Mutex<()>,
|
|
state: Mutex<LeaseState>,
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> Drop for Shared<B, C> {
|
|
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<Shared<B, C>>,
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationSession<B, C> {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
shared: Arc::clone(&self.shared),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("AuthenticationSession")
|
|
.field("timeout", &self.shared.timeout)
|
|
.field("unlock_material", &"[REDACTED]")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend> AuthenticationSession<B, SystemAuthenticationClock> {
|
|
pub fn new(
|
|
backend: B,
|
|
protections: SecretProtectionPolicy,
|
|
timeout: AuthenticationTimeout,
|
|
) -> Self {
|
|
Self::with_clock(
|
|
backend,
|
|
protections,
|
|
timeout,
|
|
SystemAuthenticationClock::default(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C> {
|
|
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<AuthenticationHandle<B, C>, 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<Option<Duration>, 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<bool, AuthenticationError> {
|
|
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<Shared<B, C>>,
|
|
generation: u64,
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationHandle<B, C> {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
shared: Arc::clone(&self.shared),
|
|
generation: self.generation,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationHandle<B, C> {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("AuthenticationHandle")
|
|
.field("generation", &"[REDACTED]")
|
|
.field("unlock_material", &"[REDACTED]")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
|
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<Duration, AuthenticationError> {
|
|
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<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
|
|
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
|
self.shared
|
|
.secret_for(self.generation, key)
|
|
.map_err(|error| match error {
|
|
AuthenticationError::Cancelled => SecretProviderError::Cancelled,
|
|
_ => SecretProviderError::Unavailable,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
|
|
for AuthenticationHandle<B, C>
|
|
{
|
|
fn credential(
|
|
&self,
|
|
server: &crate::config::ServerId,
|
|
application: &crate::config::ApplicationId,
|
|
) -> Result<GitCredential, GitError> {
|
|
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<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
|
fn operation(&self) -> Result<std::sync::MutexGuard<'_, ()>, AuthenticationError> {
|
|
self.operation
|
|
.lock()
|
|
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
|
}
|
|
|
|
fn state(&self) -> Result<std::sync::MutexGuard<'_, LeaseState>, AuthenticationError> {
|
|
self.state
|
|
.lock()
|
|
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
|
}
|
|
|
|
fn expire_if_needed(&self) -> Result<bool, AuthenticationError> {
|
|
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<T>(
|
|
&self,
|
|
generation: u64,
|
|
operation: impl FnOnce(&mut ActiveLease) -> T,
|
|
) -> Result<T, AuthenticationError> {
|
|
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<SecretBytes, AuthenticationError> {
|
|
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<NativeSecretBackend, SystemAuthenticationClock>;
|
|
|
|
impl NativeAuthenticationSession {
|
|
pub fn system(
|
|
protections: SecretProtectionPolicy,
|
|
timeout: AuthenticationTimeout,
|
|
) -> Result<Self, AuthenticationError> {
|
|
Ok(Self::new(NativeSecretBackend::new()?, protections, timeout))
|
|
}
|
|
}
|