Implement TUI authentication and inactivity relock
This commit is contained in:
@@ -11,6 +11,7 @@ pub enum Action {
|
|||||||
Command,
|
Command,
|
||||||
Cancel,
|
Cancel,
|
||||||
Lock,
|
Lock,
|
||||||
|
Unlock,
|
||||||
Refresh,
|
Refresh,
|
||||||
Next,
|
Next,
|
||||||
Previous,
|
Previous,
|
||||||
@@ -85,6 +86,13 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")),
|
bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")),
|
||||||
modes: UNLOCKED,
|
modes: UNLOCKED,
|
||||||
},
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::Unlock,
|
||||||
|
label: "unlock",
|
||||||
|
command: "unlock",
|
||||||
|
bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")),
|
||||||
|
modes: &[Mode::Locked],
|
||||||
|
},
|
||||||
ActionSpec {
|
ActionSpec {
|
||||||
action: Action::Command,
|
action: Action::Command,
|
||||||
label: "command",
|
label: "command",
|
||||||
@@ -103,7 +111,10 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
action: Action::Lock,
|
action: Action::Lock,
|
||||||
label: "lock",
|
label: "lock",
|
||||||
command: "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,
|
modes: UNLOCKED,
|
||||||
},
|
},
|
||||||
ActionSpec {
|
ActionSpec {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::collections::BTreeSet;
|
|||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::Config,
|
config::Config,
|
||||||
|
crypto::KeyInfo,
|
||||||
read::{FindResults, TreeModel},
|
read::{FindResults, TreeModel},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,11 +53,12 @@ pub struct RequestToken {
|
|||||||
pub struct StartupData {
|
pub struct StartupData {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub tree: TreeModel,
|
pub tree: TreeModel,
|
||||||
|
pub key: KeyInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum AsyncPayload {
|
pub enum AsyncPayload {
|
||||||
Startup(StartupData),
|
Startup(Box<StartupData>),
|
||||||
Refreshed(TreeModel),
|
Refreshed(TreeModel),
|
||||||
Filtered { query: String, results: FindResults },
|
Filtered { query: String, results: FindResults },
|
||||||
}
|
}
|
||||||
@@ -73,10 +75,12 @@ pub enum ResultDisposition {
|
|||||||
Stale,
|
Stale,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum AppEffect {
|
pub enum AppEffect {
|
||||||
None,
|
None,
|
||||||
RefreshTree,
|
RefreshTree,
|
||||||
|
AuthenticateEntry(String),
|
||||||
|
ManualLock,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -86,8 +90,11 @@ pub struct App {
|
|||||||
focus: PaneFocus,
|
focus: PaneFocus,
|
||||||
status: String,
|
status: String,
|
||||||
config: Option<Config>,
|
config: Option<Config>,
|
||||||
|
default_key: Option<KeyInfo>,
|
||||||
sidebar: Sidebar,
|
sidebar: Sidebar,
|
||||||
selected_entry: Option<String>,
|
selected_entry: Option<String>,
|
||||||
|
authentication_pending: Option<String>,
|
||||||
|
remaining_lease: Option<std::time::Duration>,
|
||||||
terminal_size: (u16, u16),
|
terminal_size: (u16, u16),
|
||||||
ticks: u64,
|
ticks: u64,
|
||||||
should_quit: bool,
|
should_quit: bool,
|
||||||
@@ -110,8 +117,11 @@ impl App {
|
|||||||
focus: PaneFocus::Sidebar,
|
focus: PaneFocus::Sidebar,
|
||||||
status: "Starting…".to_owned(),
|
status: "Starting…".to_owned(),
|
||||||
config: None,
|
config: None,
|
||||||
|
default_key: None,
|
||||||
sidebar: Sidebar::default(),
|
sidebar: Sidebar::default(),
|
||||||
selected_entry: None,
|
selected_entry: None,
|
||||||
|
authentication_pending: None,
|
||||||
|
remaining_lease: None,
|
||||||
terminal_size: (0, 0),
|
terminal_size: (0, 0),
|
||||||
ticks: 0,
|
ticks: 0,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
@@ -149,6 +159,18 @@ impl App {
|
|||||||
self.selected_entry.as_deref()
|
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) {
|
pub fn terminal_size(&self) -> (u16, u16) {
|
||||||
self.terminal_size
|
self.terminal_size
|
||||||
}
|
}
|
||||||
@@ -204,6 +226,7 @@ impl App {
|
|||||||
Ok(AsyncPayload::Startup(startup)) => {
|
Ok(AsyncPayload::Startup(startup)) => {
|
||||||
self.status = format!("Vault: {}", startup.config.vault().display());
|
self.status = format!("Vault: {}", startup.config.vault().display());
|
||||||
self.sidebar.replace_tree(&startup.tree);
|
self.sidebar.replace_tree(&startup.tree);
|
||||||
|
self.default_key = Some(startup.key);
|
||||||
self.config = Some(startup.config);
|
self.config = Some(startup.config);
|
||||||
}
|
}
|
||||||
Ok(AsyncPayload::Refreshed(tree)) => {
|
Ok(AsyncPayload::Refreshed(tree)) => {
|
||||||
@@ -237,6 +260,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
Action::Lock => {
|
Action::Lock => {
|
||||||
self.transition(Transition::Lock);
|
self.transition(Transition::Lock);
|
||||||
|
return AppEffect::ManualLock;
|
||||||
|
}
|
||||||
|
Action::Unlock => {
|
||||||
|
self.transition(Transition::Unlock);
|
||||||
}
|
}
|
||||||
Action::Refresh => return AppEffect::RefreshTree,
|
Action::Refresh => return AppEffect::RefreshTree,
|
||||||
Action::Next => self.sidebar.move_next(),
|
Action::Next => self.sidebar.move_next(),
|
||||||
@@ -248,9 +275,21 @@ impl App {
|
|||||||
Action::Parent => self.sidebar.collapse_or_parent(),
|
Action::Parent => self.sidebar.collapse_or_parent(),
|
||||||
Action::Child => self.sidebar.move_child(),
|
Action::Child => self.sidebar.move_child(),
|
||||||
Action::Activate => {
|
Action::Activate => {
|
||||||
if let SidebarIntent::OpenEntry(path) = self.sidebar.activate() {
|
if self.authentication_pending.is_none()
|
||||||
self.selected_entry = Some(path);
|
&& let SidebarIntent::OpenEntry(path) = self.sidebar.activate()
|
||||||
self.transition(Transition::OpenEntry);
|
{
|
||||||
|
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(),
|
Action::Filter => self.sidebar.begin_filter(),
|
||||||
@@ -267,6 +306,46 @@ impl App {
|
|||||||
AppEffect::None
|
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 {
|
pub fn transition(&mut self, transition: Transition) -> bool {
|
||||||
let current = self.mode;
|
let current = self.mode;
|
||||||
let destination = match (current, transition) {
|
let destination = match (current, transition) {
|
||||||
@@ -295,6 +374,8 @@ impl App {
|
|||||||
self.suspended_mode = Some(current);
|
self.suspended_mode = Some(current);
|
||||||
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
||||||
self.selected_entry = None;
|
self.selected_entry = None;
|
||||||
|
self.authentication_pending = None;
|
||||||
|
self.remaining_lease = None;
|
||||||
} else if destination == Mode::Locked {
|
} else if destination == Mode::Locked {
|
||||||
self.suspended_mode = None;
|
self.suspended_mode = None;
|
||||||
self.focus = PaneFocus::Sidebar;
|
self.focus = PaneFocus::Sidebar;
|
||||||
@@ -410,4 +491,35 @@ mod tests {
|
|||||||
assert_eq!(app.ticks(), 1);
|
assert_eq!(app.ticks(), 1);
|
||||||
assert_eq!(app.focus(), PaneFocus::Main);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use ratatui::DefaultTerminal;
|
|||||||
use crate::{
|
use crate::{
|
||||||
action::resolve_key,
|
action::resolve_key,
|
||||||
app::{App, AppEffect, AsyncPayload, StartupData},
|
app::{App, AppEffect, AsyncPayload, StartupData},
|
||||||
runtime::AsyncExecutor,
|
runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
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<()> {
|
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let executor = AsyncExecutor::new();
|
let executor = AsyncExecutor::new();
|
||||||
|
let mut authentication = None;
|
||||||
|
let mut authentication_initialized = false;
|
||||||
let startup = app.begin_latest_request();
|
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() {
|
while !app.should_quit() {
|
||||||
for result in executor.drain() {
|
for result in executor.drain() {
|
||||||
app.apply_result(result);
|
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()?;
|
let size = terminal.size()?;
|
||||||
app.resize(size.width, size.height);
|
app.resize(size.width, size.height);
|
||||||
terminal.draw(|frame| ui::draw(frame, &app))?;
|
terminal.draw(|frame| ui::draw(frame, &app))?;
|
||||||
if !event::poll(TICK_INTERVAL)? {
|
if !event::poll(TICK_INTERVAL)? {
|
||||||
app.tick();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
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 app.sidebar().is_editing_filter() {
|
||||||
if handle_filter_key(&mut app, key.code) {
|
if handle_filter_key(&mut app, key.code) {
|
||||||
submit_filter(&mut app, &executor);
|
submit_filter(&mut app, &executor);
|
||||||
}
|
}
|
||||||
} else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers)
|
} else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) {
|
||||||
&& app.dispatch(action) == AppEffect::RefreshTree
|
match app.dispatch(action) {
|
||||||
&& let Some(config) = app.config().cloned()
|
AppEffect::RefreshTree => {
|
||||||
{
|
if let Some(config) = app.config().cloned() {
|
||||||
let token = app.begin_latest_request();
|
let token = app.begin_latest_request();
|
||||||
executor.submit(token, move || {
|
executor.submit(token, move || {
|
||||||
load_tree(&config).map(AsyncPayload::Refreshed)
|
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::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<StartupData, String> {
|
fn load_startup() -> Result<StartupData, String> {
|
||||||
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
|
||||||
let tree = load_tree(&config)?;
|
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||||
Ok(StartupData { config, tree })
|
.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<ironstorage::read::TreeModel, String> {
|
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
use std::{
|
use std::{
|
||||||
sync::mpsc::{self, Receiver, Sender},
|
sync::mpsc::{self, Receiver, Sender},
|
||||||
thread,
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ironstorage::{
|
||||||
|
authentication::{NativeAuthenticationHandle, NativeAuthenticationSession},
|
||||||
|
crypto::KeyInfo,
|
||||||
|
secret_store::SecretProtectionPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{AsyncPayload, AsyncResult, RequestToken};
|
use crate::app::{AsyncPayload, AsyncResult, RequestToken};
|
||||||
@@ -12,6 +19,125 @@ pub struct AsyncExecutor {
|
|||||||
receiver: Receiver<AsyncResult>,
|
receiver: Receiver<AsyncResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub enum AuthenticationEvent {
|
||||||
|
Granted(String),
|
||||||
|
Failed(String),
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthenticationCompletion {
|
||||||
|
generation: u64,
|
||||||
|
entry: String,
|
||||||
|
result: Result<NativeAuthenticationHandle, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuthenticationCoordinator {
|
||||||
|
session: NativeAuthenticationSession,
|
||||||
|
key: KeyInfo,
|
||||||
|
handle: Option<NativeAuthenticationHandle>,
|
||||||
|
sender: Sender<AuthenticationCompletion>,
|
||||||
|
receiver: Receiver<AuthenticationCompletion>,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticationCoordinator {
|
||||||
|
pub fn system(
|
||||||
|
timeout: ironstorage::authentication::AuthenticationTimeout,
|
||||||
|
key: KeyInfo,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
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<AuthenticationEvent> {
|
||||||
|
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<AuthenticationEvent> {
|
||||||
|
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<AuthenticationEvent> {
|
||||||
|
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<Duration> {
|
||||||
|
self.handle
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|handle| handle.remaining_time().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&self) -> Option<NativeAuthenticationHandle> {
|
||||||
|
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 {
|
impl Default for AsyncExecutor {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|||||||
@@ -222,12 +222,18 @@ fn mode_title(mode: Mode) -> &'static str {
|
|||||||
|
|
||||||
fn status_line(app: &App) -> Paragraph<'_> {
|
fn status_line(app: &App) -> Paragraph<'_> {
|
||||||
let busy = if app.is_busy() { " [working]" } else { "" };
|
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![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
" status ",
|
" status ",
|
||||||
Style::default().bg(Color::Blue).fg(Color::White),
|
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);
|
let wide = render(140, 20, &app);
|
||||||
assert!(wide.contains("Browser"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,6 +518,8 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
|||||||
|
|
||||||
pub type NativeAuthenticationSession =
|
pub type NativeAuthenticationSession =
|
||||||
AuthenticationSession<NativeSecretBackend, SystemAuthenticationClock>;
|
AuthenticationSession<NativeSecretBackend, SystemAuthenticationClock>;
|
||||||
|
pub type NativeAuthenticationHandle =
|
||||||
|
AuthenticationHandle<NativeSecretBackend, SystemAuthenticationClock>;
|
||||||
|
|
||||||
impl NativeAuthenticationSession {
|
impl NativeAuthenticationSession {
|
||||||
pub fn system(
|
pub fn system(
|
||||||
|
|||||||
Reference in New Issue
Block a user