Implement shared authentication leases

This commit is contained in:
Hermes Agent
2026-08-10 02:18:57 +00:00
parent 2aabd589de
commit f03fdc063c
10 changed files with 1035 additions and 3 deletions

View File

@@ -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

View File

@@ -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<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))
}
}

View File

@@ -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<EditorCommand>,
clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout,
git_remotes: Vec<GitRemote>,
}
@@ -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<RawEditor>,
clipboard_timeout_seconds: Option<u64>,
#[serde(default)]
security: RawSecurity,
#[serde(default)]
git: RawGit,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSecurity {
inactivity_timeout_seconds: Option<u64>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawEditor {
@@ -473,6 +488,14 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
.map_err(|_| ConfigError::InvalidField {
field: "clipboard_timeout_seconds",
})?;
let authentication_timeout = AuthenticationTimeout::new(Duration::from_secs(
raw.security
.inactivity_timeout_seconds
.unwrap_or(DEFAULT_AUTHENTICATION_TIMEOUT.as_secs()),
))
.map_err(|_| ConfigError::InvalidField {
field: "security.inactivity_timeout_seconds",
})?;
let git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config {
@@ -482,6 +505,7 @@ fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigErro
key_material,
editor,
clipboard_timeout,
authentication_timeout,
git_remotes,
})
}
@@ -624,9 +648,16 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> 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(());
};

View File

@@ -56,6 +56,7 @@ pub struct KeyInfo {
key_id: String,
user_ids: Vec<String>,
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),
}

View File

@@ -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;

View File

@@ -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<B: SecretStoreBackend> SecretStore<B> {
}
}
pub type NativeSecretStore = SecretStore<platform::NativeSecretBackend>;
pub type NativeSecretStore = SecretStore<NativeSecretBackend>;
impl NativeSecretStore {
pub fn system(
@@ -550,7 +552,7 @@ impl NativeSecretStore {
protections: SecretProtectionPolicy,
) -> Result<Self, SecretStoreError> {
Ok(Self::new(
platform::NativeSecretBackend::new()?,
NativeSecretBackend::new()?,
cache_policy,
protections,
))

View 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(())
}

View File

@@ -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<dyn Error>>;
@@ -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(())
}

View File

@@ -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.

View File

@@ -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