From 57a79b7d2185e4ad4c6c2347333428c09724a9c2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 06:22:23 +0000 Subject: [PATCH] Implement TUI authentication and inactivity relock --- apps/tui/src/action.rs | 13 ++- apps/tui/src/app.rs | 122 ++++++++++++++++++++++++-- apps/tui/src/lib.rs | 105 +++++++++++++++++++--- apps/tui/src/runtime.rs | 126 +++++++++++++++++++++++++++ apps/tui/src/ui.rs | 16 +++- crates/storage/src/authentication.rs | 2 + 6 files changed, 365 insertions(+), 19 deletions(-) diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 5f649ad..14c61bc 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -11,6 +11,7 @@ pub enum Action { Command, Cancel, Lock, + Unlock, Refresh, Next, Previous, @@ -85,6 +86,13 @@ pub static ACTIONS: &[ActionSpec] = &[ bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")), modes: UNLOCKED, }, + ActionSpec { + action: Action::Unlock, + label: "unlock", + command: "unlock", + bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")), + modes: &[Mode::Locked], + }, ActionSpec { action: Action::Command, label: "command", @@ -103,7 +111,10 @@ pub static ACTIONS: &[ActionSpec] = &[ action: Action::Lock, label: "lock", command: "lock", - bindings: keys!((KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l")), + bindings: keys!( + (KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l"), + (KeyCode::Char('z'), KeyModifiers::CONTROL, "C-z"), + ), modes: UNLOCKED, }, ActionSpec { diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 7860d29..eaafe87 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -4,6 +4,7 @@ use std::collections::BTreeSet; use ironstorage::{ config::Config, + crypto::KeyInfo, read::{FindResults, TreeModel}, }; @@ -52,11 +53,12 @@ pub struct RequestToken { pub struct StartupData { pub config: Config, pub tree: TreeModel, + pub key: KeyInfo, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum AsyncPayload { - Startup(StartupData), + Startup(Box), Refreshed(TreeModel), Filtered { query: String, results: FindResults }, } @@ -73,10 +75,12 @@ pub enum ResultDisposition { Stale, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum AppEffect { None, RefreshTree, + AuthenticateEntry(String), + ManualLock, } #[derive(Debug)] @@ -86,8 +90,11 @@ pub struct App { focus: PaneFocus, status: String, config: Option, + default_key: Option, sidebar: Sidebar, selected_entry: Option, + authentication_pending: Option, + remaining_lease: Option, terminal_size: (u16, u16), ticks: u64, should_quit: bool, @@ -110,8 +117,11 @@ impl App { focus: PaneFocus::Sidebar, status: "Starting…".to_owned(), config: None, + default_key: None, sidebar: Sidebar::default(), selected_entry: None, + authentication_pending: None, + remaining_lease: None, terminal_size: (0, 0), ticks: 0, should_quit: false, @@ -149,6 +159,18 @@ impl App { self.selected_entry.as_deref() } + pub fn default_key(&self) -> Option<&KeyInfo> { + self.default_key.as_ref() + } + + pub fn authentication_pending(&self) -> bool { + self.authentication_pending.is_some() + } + + pub fn remaining_lease(&self) -> Option { + self.remaining_lease + } + pub fn terminal_size(&self) -> (u16, u16) { self.terminal_size } @@ -204,6 +226,7 @@ impl App { Ok(AsyncPayload::Startup(startup)) => { self.status = format!("Vault: {}", startup.config.vault().display()); self.sidebar.replace_tree(&startup.tree); + self.default_key = Some(startup.key); self.config = Some(startup.config); } Ok(AsyncPayload::Refreshed(tree)) => { @@ -237,6 +260,10 @@ impl App { } Action::Lock => { self.transition(Transition::Lock); + return AppEffect::ManualLock; + } + Action::Unlock => { + self.transition(Transition::Unlock); } Action::Refresh => return AppEffect::RefreshTree, Action::Next => self.sidebar.move_next(), @@ -248,9 +275,21 @@ impl App { Action::Parent => self.sidebar.collapse_or_parent(), Action::Child => self.sidebar.move_child(), Action::Activate => { - if let SidebarIntent::OpenEntry(path) = self.sidebar.activate() { - self.selected_entry = Some(path); - self.transition(Transition::OpenEntry); + if self.authentication_pending.is_none() + && let SidebarIntent::OpenEntry(path) = self.sidebar.activate() + { + self.authentication_pending = Some(path.clone()); + self.status = if self + .default_key + .as_ref() + .is_some_and(KeyInfo::requires_passphrase) + { + "Authenticating through secure storage for the OpenPGP passphrase…" + .to_owned() + } else { + "Authenticating through secure storage…".to_owned() + }; + return AppEffect::AuthenticateEntry(path); } } Action::Filter => self.sidebar.begin_filter(), @@ -267,6 +306,46 @@ impl App { AppEffect::None } + pub fn authentication_granted(&mut self, entry: String) -> bool { + if self.authentication_pending.as_deref() != Some(&entry) { + return false; + } + self.authentication_pending = None; + self.selected_entry = Some(entry); + self.status = "Authenticated".to_owned(); + self.transition(Transition::OpenEntry) + } + + pub fn authentication_failed(&mut self, message: String) { + self.authentication_pending = None; + self.selected_entry = None; + self.status = message; + if self.mode != Mode::Browser { + self.mode = Mode::Browser; + self.focus = PaneFocus::Sidebar; + } + } + + pub fn report_status(&mut self, message: String) { + self.status = message; + } + + pub fn update_remaining_lease(&mut self, remaining: Option) { + self.remaining_lease = remaining; + } + + pub fn forced_relock(&mut self, reason: &'static str) { + let discarded_edit = self.mode == Mode::Editor; + self.transition(Transition::Lock); + self.authentication_pending = None; + self.remaining_lease = None; + self.status = if discarded_edit { + format!("Locked: {reason}; unsaved edits were discarded") + } else { + format!("Locked: {reason}") + }; + } + pub fn transition(&mut self, transition: Transition) -> bool { let current = self.mode; let destination = match (current, transition) { @@ -295,6 +374,8 @@ impl App { self.suspended_mode = Some(current); } else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) { self.selected_entry = None; + self.authentication_pending = None; + self.remaining_lease = None; } else if destination == Mode::Locked { self.suspended_mode = None; self.focus = PaneFocus::Sidebar; @@ -410,4 +491,35 @@ mod tests { assert_eq!(app.ticks(), 1); assert_eq!(app.focus(), PaneFocus::Main); } + + #[test] + fn authentication_must_match_the_pending_entry_before_viewer_transition() { + let mut app = App::new(); + app.authentication_pending = Some("expected".to_owned()); + assert!(!app.authentication_granted("stale".to_owned())); + assert_eq!(app.mode(), Mode::Browser); + assert!(app.authentication_granted("expected".to_owned())); + assert_eq!(app.mode(), Mode::Viewer); + assert_eq!(app.selected_entry(), Some("expected")); + } + + #[test] + fn denial_expiry_and_forced_editor_relock_remove_entry_state() { + let mut app = App::new(); + app.authentication_pending = Some("secret".to_owned()); + app.authentication_failed("authentication was denied".to_owned()); + assert_eq!(app.mode(), Mode::Browser); + assert_eq!(app.selected_entry(), None); + assert!(!app.authentication_pending()); + + app.mode = Mode::Editor; + app.selected_entry = Some("secret".to_owned()); + app.remaining_lease = Some(std::time::Duration::from_secs(1)); + app.forced_relock("authentication lease expired"); + assert_eq!(app.mode(), Mode::Locked); + assert_eq!(app.focus(), PaneFocus::Sidebar); + assert_eq!(app.selected_entry(), None); + assert_eq!(app.remaining_lease(), None); + assert!(app.status().contains("unsaved edits were discarded")); + } } diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 56244da..56d1629 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -18,7 +18,7 @@ use ratatui::DefaultTerminal; use crate::{ action::resolve_key, app::{App, AppEffect, AsyncPayload, StartupData}, - runtime::AsyncExecutor, + runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent}, }; const TICK_INTERVAL: Duration = Duration::from_millis(250); @@ -28,39 +28,97 @@ const TICK_INTERVAL: Duration = Duration::from_millis(250); pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut app = App::new(); let executor = AsyncExecutor::new(); + let mut authentication = None; + let mut authentication_initialized = false; let startup = app.begin_latest_request(); - executor.submit(startup, || load_startup().map(AsyncPayload::Startup)); + executor.submit(startup, || { + load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup))) + }); while !app.should_quit() { for result in executor.drain() { app.apply_result(result); } + if !authentication_initialized + && let (Some(config), Some(key)) = (app.config(), app.default_key()) + { + authentication_initialized = true; + match AuthenticationCoordinator::system(config.authentication_timeout(), key.clone()) { + Ok(coordinator) => authentication = Some(coordinator), + Err(error) => app.authentication_failed(error), + } + } + if let Some(coordinator) = authentication.as_mut() + && let Some(event) = coordinator.completion() + { + apply_authentication_event(&mut app, event); + } let size = terminal.size()?; app.resize(size.width, size.height); terminal.draw(|frame| ui::draw(frame, &app))?; if !event::poll(TICK_INTERVAL)? { app.tick(); + if let Some(coordinator) = authentication.as_mut() { + if let Some(event) = coordinator.poll_lease() { + apply_authentication_event(&mut app, event); + } + app.update_remaining_lease(coordinator.remaining_time()); + } continue; } match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(coordinator) = authentication.as_mut() + && let Some(event) = coordinator.touch_user_activity() + { + apply_authentication_event(&mut app, event); + } if app.sidebar().is_editing_filter() { if handle_filter_key(&mut app, key.code) { submit_filter(&mut app, &executor); } - } else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) - && app.dispatch(action) == AppEffect::RefreshTree - && let Some(config) = app.config().cloned() - { - let token = app.begin_latest_request(); - executor.submit(token, move || { - load_tree(&config).map(AsyncPayload::Refreshed) - }); + } else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { + match app.dispatch(action) { + AppEffect::RefreshTree => { + if let Some(config) = app.config().cloned() { + let token = app.begin_latest_request(); + executor.submit(token, move || { + load_tree(&config).map(AsyncPayload::Refreshed) + }); + } + } + AppEffect::AuthenticateEntry(entry) => { + if let Some(coordinator) = authentication.as_mut() { + coordinator.request(entry); + } else { + app.authentication_failed( + "operating-system secure storage is unavailable".to_owned(), + ); + } + } + AppEffect::ManualLock => { + if let Some(coordinator) = authentication.as_mut() + && let Err(error) = coordinator.lock() + { + app.forced_relock("manual lock requested"); + app.report_status(format!( + "locked after secure-store cleanup failed: {error}" + )); + } + } + AppEffect::None => {} + } } } Event::Resize(width, height) => app.resize(width, height), + Event::FocusLost => { + if let Some(coordinator) = authentication.as_mut() { + let _ignored = coordinator.lock(); + } + app.forced_relock("terminal ownership was lost"); + } _ => {} } } @@ -69,8 +127,31 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { fn load_startup() -> Result { let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; - let tree = load_tree(&config)?; - Ok(StartupData { config, tree }) + let repository = ironstorage::repository::Repository::open(config.vault()) + .map_err(|error| error.to_string())?; + let keys = ironstorage::crypto::KeyStore::load(config.key_material()) + .map_err(|error| error.to_string())?; + let key_handle = keys + .resolve(config.default_key().as_str()) + .map_err(|error| error.to_string())?; + let key = keys + .infos() + .find(|key| key.fingerprint() == key_handle.fingerprint()) + .ok_or_else(|| "the configured OpenPGP key is unavailable".to_owned())?; + let tree = ironstorage::read::VaultReader::new(&repository, &keys) + .list(&ironstorage::repository::DirectoryPath::root()) + .map_err(|error| error.to_string())?; + Ok(StartupData { config, tree, key }) +} + +fn apply_authentication_event(app: &mut App, event: AuthenticationEvent) { + match event { + AuthenticationEvent::Granted(entry) => { + app.authentication_granted(entry); + } + AuthenticationEvent::Failed(error) => app.authentication_failed(error), + AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"), + } } fn load_tree(config: &ironstorage::config::Config) -> Result { diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 3206150..e392085 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -3,6 +3,13 @@ use std::{ sync::mpsc::{self, Receiver, Sender}, thread, + time::Duration, +}; + +use ironstorage::{ + authentication::{NativeAuthenticationHandle, NativeAuthenticationSession}, + crypto::KeyInfo, + secret_store::SecretProtectionPolicy, }; use crate::app::{AsyncPayload, AsyncResult, RequestToken}; @@ -12,6 +19,125 @@ pub struct AsyncExecutor { receiver: Receiver, } +#[derive(Debug, Eq, PartialEq)] +pub enum AuthenticationEvent { + Granted(String), + Failed(String), + Expired, +} + +struct AuthenticationCompletion { + generation: u64, + entry: String, + result: Result, +} + +pub struct AuthenticationCoordinator { + session: NativeAuthenticationSession, + key: KeyInfo, + handle: Option, + sender: Sender, + receiver: Receiver, + generation: u64, +} + +impl AuthenticationCoordinator { + pub fn system( + timeout: ironstorage::authentication::AuthenticationTimeout, + key: KeyInfo, + ) -> Result { + let session = + NativeAuthenticationSession::system(SecretProtectionPolicy::default(), timeout) + .map_err(|error| error.to_string())?; + let (sender, receiver) = mpsc::channel(); + Ok(Self { + session, + key, + handle: None, + sender, + receiver, + generation: 0, + }) + } + + pub fn request(&mut self, entry: String) { + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + let session = self.session.clone(); + let key = self.key.clone(); + let sender = self.sender.clone(); + thread::spawn(move || { + let result = session + .authenticate(&key) + .map_err(|error| error.to_string()); + let _ignored = sender.send(AuthenticationCompletion { + generation, + entry, + result, + }); + }); + } + + pub fn completion(&mut self) -> Option { + let completion = self + .receiver + .try_iter() + .filter(|completion| completion.generation == self.generation) + .last()?; + match completion.result { + Ok(handle) => { + self.handle = Some(handle); + Some(AuthenticationEvent::Granted(completion.entry)) + } + Err(error) => { + self.handle = None; + Some(AuthenticationEvent::Failed(error)) + } + } + } + + pub fn touch_user_activity(&mut self) -> Option { + let handle = self.handle.as_ref()?; + if let Err(error) = handle.touch_user_activity() { + self.handle = None; + return Some(AuthenticationEvent::Failed(error.to_string())); + } + None + } + + pub fn poll_lease(&mut self) -> Option { + match self.session.expire() { + Ok(true) => { + self.handle = None; + Some(AuthenticationEvent::Expired) + } + Ok(false) => None, + Err(error) => { + self.handle = None; + Some(AuthenticationEvent::Failed(error.to_string())) + } + } + } + + pub fn remaining_time(&self) -> Option { + self.handle + .as_ref() + .and_then(|handle| handle.remaining_time().ok()) + } + + pub fn handle(&self) -> Option { + self.handle.clone() + } + + pub fn lock(&mut self) -> Result<(), String> { + self.generation = self.generation.wrapping_add(1); + self.handle = None; + self.session + .manual_lock() + .map_err(|error| error.to_string()) + } +} + impl Default for AsyncExecutor { fn default() -> Self { Self::new() diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 0472ce3..daf21d2 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -222,12 +222,18 @@ fn mode_title(mode: Mode) -> &'static str { fn status_line(app: &App) -> Paragraph<'_> { let busy = if app.is_busy() { " [working]" } else { "" }; + let warning = app + .remaining_lease() + .filter(|remaining| remaining.as_secs() <= 30) + .map_or_else(String::new, |remaining| { + format!(" [locks in {}s]", remaining.as_secs()) + }); Paragraph::new(Line::from(vec![ Span::styled( " status ", Style::default().bg(Color::Blue).fg(Color::White), ), - Span::raw(format!(" {}{busy}", app.status())), + Span::raw(format!(" {}{busy}{warning}", app.status())), ])) } @@ -370,4 +376,12 @@ mod tests { let wide = render(140, 20, &app); assert!(wide.contains("Browser")); } + + #[test] + fn lease_warning_is_concise_and_never_contains_entry_state() { + let mut app = App::new(); + app.update_remaining_lease(Some(std::time::Duration::from_secs(30))); + let output = render(100, 20, &app); + assert!(output.contains("locks in 30s")); + } } diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs index 75f1cd3..2ec8955 100644 --- a/crates/storage/src/authentication.rs +++ b/crates/storage/src/authentication.rs @@ -518,6 +518,8 @@ impl Shared { pub type NativeAuthenticationSession = AuthenticationSession; +pub type NativeAuthenticationHandle = + AuthenticationHandle; impl NativeAuthenticationSession { pub fn system(