Implement TUI authentication and inactivity relock
This commit is contained in:
@@ -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<StartupData>),
|
||||
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<Config>,
|
||||
default_key: Option<KeyInfo>,
|
||||
sidebar: Sidebar,
|
||||
selected_entry: Option<String>,
|
||||
authentication_pending: Option<String>,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
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<std::time::Duration> {
|
||||
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<std::time::Duration>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user