Implement shared authentication leases
This commit is contained in:
@@ -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
|
||||
|
||||
529
crates/storage/src/authentication.rs
Normal file
529
crates/storage/src/authentication.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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(());
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
358
crates/storage/tests/authentication_leases.rs
Normal file
358
crates/storage/tests/authentication_leases.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
authentication::{
|
||||
AuthenticationClock, AuthenticationError, AuthenticationSession, AuthenticationTimeout,
|
||||
MAX_AUTHENTICATION_TIMEOUT,
|
||||
},
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider as _},
|
||||
read::{ShowResult, VaultReader},
|
||||
repository::{Repository, SecretBytes},
|
||||
secret_store::{
|
||||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||||
SecretReference, SecretStore, SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
};
|
||||
use support::compatibility::FixtureSet;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackendState {
|
||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||
fault: Option<SecretStoreError>,
|
||||
retrieves: usize,
|
||||
locks: usize,
|
||||
unlocks: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryBackend(Arc<Mutex<BackendState>>);
|
||||
|
||||
impl MemoryBackend {
|
||||
fn fail_next(&self, error: SecretStoreError) {
|
||||
self.0.lock().expect("test mutex").fault = Some(error);
|
||||
}
|
||||
|
||||
fn retrieves(&self) -> usize {
|
||||
self.0.lock().expect("test mutex").retrieves
|
||||
}
|
||||
|
||||
fn locks(&self) -> usize {
|
||||
self.0.lock().expect("test mutex").locks
|
||||
}
|
||||
|
||||
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
|
||||
match state.fault.take() {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStoreBackend for MemoryBackend {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
if state.values.contains_key(locator) {
|
||||
return Err(SecretStoreError::AlreadyExists);
|
||||
}
|
||||
state
|
||||
.values
|
||||
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.retrieves += 1;
|
||||
state
|
||||
.values
|
||||
.get(locator)
|
||||
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
let existing = state
|
||||
.values
|
||||
.get_mut(locator)
|
||||
.ok_or(SecretStoreError::Missing)?;
|
||||
*existing = SecretBytes::new(value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
_protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state
|
||||
.values
|
||||
.remove(locator)
|
||||
.map(drop)
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.locks += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
state.unlocks += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ManualClock(Arc<Mutex<Duration>>);
|
||||
|
||||
impl ManualClock {
|
||||
fn advance(&self, duration: Duration) {
|
||||
let mut now = self.0.lock().expect("test clock");
|
||||
*now += duration;
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticationClock for ManualClock {
|
||||
fn now(&self) -> Duration {
|
||||
*self.0.lock().expect("test clock")
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_key(fixture: &FixtureSet, keys: &KeyStore, name: &str) -> TestResult<KeyInfo> {
|
||||
let fingerprint = &fixture.key(name)?.primary_fingerprint;
|
||||
keys.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or_else(|| format!("missing imported fixture key {name}").into())
|
||||
}
|
||||
|
||||
fn provision_passphrase(backend: MemoryBackend, key: &KeyInfo, passphrase: &[u8]) -> TestResult {
|
||||
let store = SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
store.unlock()?;
|
||||
store.create(
|
||||
&SecretReference::openpgp_passphrase(key.fingerprint().as_str())?,
|
||||
SecretBytes::new(passphrase.to_vec()),
|
||||
)?;
|
||||
store.lock()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session(
|
||||
backend: MemoryBackend,
|
||||
clock: ManualClock,
|
||||
seconds: u64,
|
||||
) -> Result<AuthenticationSession<MemoryBackend, ManualClock>, AuthenticationError> {
|
||||
Ok(AuthenticationSession::with_clock(
|
||||
backend,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
AuthenticationTimeout::new(Duration::from_secs(seconds))?,
|
||||
clock,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let baseline_locks = backend.locks();
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend.clone(), clock.clone(), 120)?;
|
||||
let mut handle = session.authenticate(&alice)?;
|
||||
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(120));
|
||||
assert_eq!(backend.retrieves(), 1);
|
||||
clock.advance(Duration::from_secs(100));
|
||||
assert_eq!(session.remaining_time()?, Some(Duration::from_secs(20)));
|
||||
assert_eq!(
|
||||
handle
|
||||
.secret_for(&alice)
|
||||
.expect("active lease supplies cached passphrase")
|
||||
.expose(),
|
||||
b"fixture-alice-passphrase"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
1,
|
||||
"the lease owns the only memory cache"
|
||||
);
|
||||
clock.advance(Duration::from_secs(20));
|
||||
assert!(session.expire()?);
|
||||
assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired));
|
||||
assert_eq!(session.remaining_time()?, None);
|
||||
assert_eq!(backend.locks(), baseline_locks + 1);
|
||||
|
||||
let handle = session.authenticate(&alice)?;
|
||||
clock.advance(Duration::from_secs(100));
|
||||
handle.touch_user_activity()?;
|
||||
clock.advance(Duration::from_secs(119));
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(1));
|
||||
clock.advance(Duration::from_secs(1));
|
||||
assert_eq!(handle.ensure_active(), Err(AuthenticationError::Expired));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend.clone(), clock.clone(), 10)?;
|
||||
|
||||
let first = session.authenticate(&alice)?;
|
||||
let first_clone = first.clone();
|
||||
session.manual_lock()?;
|
||||
assert_eq!(first.ensure_active(), Err(AuthenticationError::Revoked));
|
||||
assert_eq!(
|
||||
first_clone.ensure_active(),
|
||||
Err(AuthenticationError::Revoked)
|
||||
);
|
||||
|
||||
let second = session.authenticate(&alice)?;
|
||||
session.cancel()?;
|
||||
assert_eq!(second.ensure_active(), Err(AuthenticationError::Cancelled));
|
||||
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
session.authenticate(&alice),
|
||||
Err(AuthenticationError::Cancelled)
|
||||
));
|
||||
assert_eq!(session.remaining_time()?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relock_clears_unlock_material_and_old_handles_cannot_open_entries() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend.clone(), clock.clone(), 5)?;
|
||||
let reader = VaultReader::new(&repository, &keys);
|
||||
let mut old_handle = session.authenticate(&alice)?;
|
||||
|
||||
match reader.show(Some("email/personal"), &mut old_handle)? {
|
||||
ShowResult::Entry(contents) => assert_eq!(
|
||||
contents.expose(),
|
||||
fixture.read("expected/basic/email/personal.txt")?
|
||||
),
|
||||
ShowResult::Directory(_) => panic!("fixture entry resolved as a directory"),
|
||||
}
|
||||
assert_eq!(backend.retrieves(), 1);
|
||||
session.manual_lock()?;
|
||||
assert!(
|
||||
reader
|
||||
.show(Some("email/personal"), &mut old_handle)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut new_handle = session.authenticate(&alice)?;
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
2,
|
||||
"relock discarded cached passphrase bytes"
|
||||
);
|
||||
assert!(matches!(
|
||||
reader.show(Some("email/personal"), &mut new_handle)?,
|
||||
ShowResult::Entry(_)
|
||||
));
|
||||
let debug = format!("{session:?} {new_handle:?}");
|
||||
assert!(!debug.contains("fixture-alice-passphrase"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> TestResult {
|
||||
assert_eq!(
|
||||
AuthenticationTimeout::new(Duration::ZERO),
|
||||
Err(AuthenticationError::InvalidTimeout)
|
||||
);
|
||||
assert_eq!(
|
||||
AuthenticationTimeout::new(MAX_AUTHENTICATION_TIMEOUT + Duration::from_secs(1)),
|
||||
Err(AuthenticationError::InvalidTimeout)
|
||||
);
|
||||
|
||||
let fixture = FixtureSet::load()?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend.clone(), clock.clone(), 1)?;
|
||||
let handle = session.authenticate(&alice)?;
|
||||
let baseline_locks = backend.locks();
|
||||
clock.advance(Duration::from_secs(1));
|
||||
|
||||
let expiry_session = session.clone();
|
||||
let expiry = std::thread::spawn(move || expiry_session.expire());
|
||||
let access = std::thread::spawn(move || handle.ensure_active());
|
||||
let expiry_result = expiry.join().expect("expiry thread")?;
|
||||
let access_result = access.join().expect("access thread");
|
||||
assert!(matches!(access_result, Err(AuthenticationError::Expired)));
|
||||
assert!(expiry_result || session.remaining_time()?.is_none());
|
||||
assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
|
||||
Ok(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
47
docs/authentication-leases.md
Normal file
47
docs/authentication-leases.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user