diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 11edf79..c6059b3 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -52,6 +52,14 @@ pub enum Action { CopyEntry, GitPull, GitPush, + OtpCode, + OtpCopyCode, + OtpUri, + OtpCopyUri, + OtpQr, + OtpInsert, + OtpAppend, + OtpValidate, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -567,6 +575,110 @@ pub static ACTIONS: &[ActionSpec] = &[ )), modes: BROWSER_LIKE, }, + ActionSpec { + action: Action::OtpCode, + label: "generate OTP code", + command: "otp-code", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('c'), + KeyModifiers::NONE, + "o c" + )), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::OtpCopyCode, + label: "copy OTP code", + command: "otp-copy-code", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('y'), + KeyModifiers::NONE, + "o y" + )), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::OtpUri, + label: "show OTP URI", + command: "otp-uri", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('u'), + KeyModifiers::NONE, + "o u" + )), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::OtpCopyUri, + label: "copy OTP URI", + command: "otp-copy-uri", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('x'), + KeyModifiers::NONE, + "o x" + )), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::OtpQr, + label: "show OTP QR", + command: "otp-qr", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('q'), + KeyModifiers::NONE, + "o q" + )), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::OtpInsert, + label: "insert OTP URI", + command: "otp-insert", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('i'), + KeyModifiers::NONE, + "o i" + )), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::OtpAppend, + label: "append OTP URI", + command: "otp-append", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('a'), + KeyModifiers::NONE, + "o a" + )), + modes: ENTRY_CONTEXT, + }, + ActionSpec { + action: Action::OtpValidate, + label: "validate OTP URI", + command: "otp-validate", + bindings: sequences!(( + KeyCode::Char('o'), + KeyModifiers::NONE, + KeyCode::Char('v'), + KeyModifiers::NONE, + "o v" + )), + modes: BROWSER_LIKE, + }, ]; pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index da28a77..8706458 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -4,13 +4,14 @@ use std::collections::BTreeSet; use ironstorage::{ command::{ - CommandRequest, OtpRequest, Presentation, help_text, otp_version_text, version_text, + CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, + Presentation, help_text, otp_version_text, version_text, }, config::Config, crypto::KeyInfo, document::{DocumentError, EntryDocument, EntryFieldId}, git::{GitConflict, GitProgressPhase, GitSnapshot}, - presentation::ClipboardDisposition, + presentation::{ClipboardDisposition, QrMatrix}, read::{FindResults, GrepResults, TreeModel}, repository::SecretBytes, write::WriteOutcome, @@ -23,7 +24,7 @@ use crate::{ search::GrepView, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, - workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission}, + workflow::{OtpFormKind, WorkflowForm, WorkflowInput, WorkflowSubmission}, }; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -88,6 +89,47 @@ pub struct GitView { details: Option, } +#[derive(Debug)] +pub struct OtpDisplay { + entry: String, + field: Option, + code: SecretBytes, + remaining_seconds: Option, + counter: Option, +} + +impl OtpDisplay { + pub fn entry(&self) -> &str { + &self.entry + } + pub fn field(&self) -> Option { + self.field + } + pub fn code(&self) -> &SecretBytes { + &self.code + } + pub fn remaining_seconds(&self) -> Option { + self.remaining_seconds + } + pub fn counter(&self) -> Option { + self.counter + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpPresentationTarget { + Terminal, + Clipboard, + Qr, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpUiRequest { + pub request: OtpRequest, + pub confirmed_hotp: bool, + pub field: Option, +} + impl GitView { pub fn snapshot(&self) -> &GitSnapshot { &self.snapshot @@ -138,6 +180,22 @@ pub enum AsyncPayload { conflicts: Vec, details: Option, }, + OtpCodeFinished { + entry: String, + field: Option, + code: SecretBytes, + remaining_seconds: Option, + counter: Option, + clipboard: bool, + tree: Option, + }, + OtpUriFinished { + entry: String, + presentation: OtpPresentationTarget, + payload: SecretBytes, + qr: Option, + }, + OtpValidated, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -195,6 +253,7 @@ pub enum AppEffect { AuthenticateGit(ironstorage::command::GitRequest), CancelGit, ResolveGit(Vec), + AuthenticateOtp(OtpUiRequest), RunCommand(CommandRequest), ManualLock, } @@ -229,6 +288,12 @@ pub struct App { grep_view: Option, git_view: Option, git_pending: bool, + otp_display: Option, + qr_popup: Option, + uri_popup: Option, + otp_pending: bool, + hotp_confirmation: Option, + clipboard_request: Option, remaining_lease: Option, terminal_size: (u16, u16), ticks: u64, @@ -269,6 +334,12 @@ impl App { grep_view: None, git_view: None, git_pending: false, + otp_display: None, + qr_popup: None, + uri_popup: None, + otp_pending: false, + hotp_confirmation: None, + clipboard_request: None, remaining_lease: None, terminal_size: (0, 0), ticks: 0, @@ -367,6 +438,29 @@ impl App { self.git_pending } + pub fn otp_display(&self) -> Option<&OtpDisplay> { + self.otp_display.as_ref().filter(|display| { + self.selected_entry + .as_deref() + .is_some_and(|entry| entry == display.entry()) + }) + } + pub fn qr_popup(&self) -> Option<&QrMatrix> { + self.qr_popup.as_ref() + } + pub fn uri_popup(&self) -> Option<&SecretBytes> { + self.uri_popup.as_ref() + } + pub fn otp_pending(&self) -> bool { + self.otp_pending + } + pub fn hotp_confirmation(&self) -> bool { + self.hotp_confirmation.is_some() + } + pub fn take_clipboard_request(&mut self) -> Option { + self.clipboard_request.take() + } + pub fn begin_git_operation(&mut self, label: &str) { self.git_pending = true; self.status = format!("{label} queued for secure-storage authentication…"); @@ -406,6 +500,36 @@ impl App { pub fn tick(&mut self) { self.ticks = self.ticks.wrapping_add(1); + if self.ticks.is_multiple_of(4) + && let Some(remaining) = self + .otp_display + .as_mut() + .and_then(|display| display.remaining_seconds.as_mut()) + { + *remaining = remaining.saturating_sub(1); + } + } + + pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> { + if self.otp_pending || !self.ticks.is_multiple_of(4) || self.mode != Mode::Viewer { + return None; + } + let viewer = self.viewer.as_ref()?; + let field = viewer.focused_field()?; + let otp = field.metadata().otp()?; + if otp.kind() != ironstorage::otp::OtpKind::Totp { + return None; + } + let entry = self.selected_entry.clone()?; + if self.otp_display.as_ref().is_some_and(|display| { + display.entry == entry + && display.field == Some(field.id()) + && display.remaining_seconds != Some(0) + }) { + return None; + } + self.otp_pending = true; + Some((entry, field.id())) } pub fn begin_request(&mut self) -> RequestToken { @@ -478,6 +602,9 @@ impl App { self.focus = PaneFocus::Main; } Ok(AsyncPayload::ClipboardFinished(disposition)) => { + if self.mode == Mode::Locked { + return ResultDisposition::Applied; + } self.status = match disposition { ClipboardDisposition::RestoredPrevious => { "Clipboard restored to its previous value".to_owned() @@ -625,8 +752,66 @@ impl App { self.focus = PaneFocus::Main; self.status = message; } + Ok(AsyncPayload::OtpCodeFinished { + entry, + field, + code, + remaining_seconds, + counter, + clipboard, + tree, + }) => { + self.otp_pending = false; + if let Some(tree) = tree { + self.sidebar.replace_tree(&tree); + } + if clipboard { + self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec())); + } + self.status = if let Some(counter) = counter { + format!("Generated and committed HOTP counter {counter}") + } else { + "TOTP code refreshed".to_owned() + }; + self.otp_display = Some(OtpDisplay { + entry, + field, + code, + remaining_seconds, + counter, + }); + self.hotp_confirmation = None; + } + Ok(AsyncPayload::OtpUriFinished { + entry, + presentation, + payload, + qr, + }) => { + self.otp_pending = false; + self.status = format!("Presented OTP URI for {entry}"); + match presentation { + OtpPresentationTarget::Terminal => { + self.qr_popup = None; + self.otp_display = None; + self.uri_popup = Some(payload); + } + OtpPresentationTarget::Clipboard => { + self.clipboard_request = Some(payload); + } + OtpPresentationTarget::Qr => { + self.uri_popup = None; + self.qr_popup = qr; + } + } + } + Ok(AsyncPayload::OtpValidated) => { + self.otp_pending = false; + self.status = "OTP URI is valid".to_owned(); + } Err(error) => { self.git_pending = false; + self.otp_pending = false; self.status = error; self.editor_generation_pending = None; self.command_open_target = None; @@ -671,7 +856,15 @@ impl App { self.transition(Transition::OpenCommand); } Action::Cancel => { - if self.git_pending { + if self.qr_popup.is_some() || self.uri_popup.is_some() { + self.qr_popup = None; + self.uri_popup = None; + self.status = "OTP presentation closed".to_owned(); + } else if self.hotp_confirmation.is_some() { + self.hotp_confirmation = None; + self.status = "HOTP generation cancelled; counter unchanged".to_owned(); + self.transition(Transition::Dismiss); + } else if self.git_pending { self.status = "Cancelling Git operation…".to_owned(); return AppEffect::CancelGit; } else if self.git_view.is_some() && self.mode == Mode::Browser { @@ -765,11 +958,13 @@ impl App { Action::FocusNext if self.mode == Mode::Viewer => { if let Some(viewer) = self.viewer.as_mut() { viewer.focus_next(); + self.otp_display = None; } } Action::FocusPrevious if self.mode == Mode::Viewer => { if let Some(viewer) = self.viewer.as_mut() { viewer.focus_previous(); + self.otp_display = None; } } Action::FocusNext if self.mode == Mode::Editor => { @@ -890,12 +1085,20 @@ impl App { } } Action::ConfirmDiscard => { - if self.discard_confirmation { + if let Some(mut request) = self.hotp_confirmation.take() { + request.confirmed_hotp = true; + self.transition(Transition::Dismiss); + self.otp_pending = true; + return AppEffect::AuthenticateOtp(request); + } else if self.discard_confirmation { self.discard_editor(); } } Action::KeepEditing => { - if self.discard_confirmation { + if self.hotp_confirmation.take().is_some() { + self.transition(Transition::Dismiss); + self.status = "HOTP generation cancelled; counter unchanged".to_owned(); + } else if self.discard_confirmation { self.keep_editing(); } } @@ -937,6 +1140,71 @@ impl App { }); } Action::Quit => {} + Action::OtpCode + | Action::OtpCopyCode + | Action::OtpUri + | Action::OtpCopyUri + | Action::OtpQr => { + let Some(entry) = self.selected_entry.clone() else { + self.status = "Select an OTP entry first".to_owned(); + return AppEffect::None; + }; + let Some((field, kind)) = self + .viewer + .as_ref() + .and_then(EntryViewer::focused_field) + .and_then(|field| field.metadata().otp().map(|otp| (field.id(), otp.kind()))) + else { + self.status = "Focus an OTP field first".to_owned(); + return AppEffect::None; + }; + let request = match action { + Action::OtpCode | Action::OtpCopyCode => OtpRequest::Code(OtpCodeRequest { + entry, + clipboard: action == Action::OtpCopyCode, + }), + Action::OtpUri | Action::OtpCopyUri | Action::OtpQr => { + OtpRequest::Uri(OtpUriRequest { + entry, + presentation: match action { + Action::OtpUri => OtpUriPresentation::Terminal, + Action::OtpCopyUri => OtpUriPresentation::Clipboard, + Action::OtpQr => OtpUriPresentation::QrCode, + _ => unreachable!(), + }, + }) + } + _ => unreachable!(), + }; + let ui_request = OtpUiRequest { + request, + confirmed_hotp: false, + field: Some(field), + }; + if matches!(ui_request.request, OtpRequest::Code(_)) + && kind == ironstorage::otp::OtpKind::Hotp + { + self.hotp_confirmation = Some(ui_request); + self.transition(Transition::OpenDialog); + self.status = "Generate HOTP and commit the advanced counter? y/n".to_owned(); + return AppEffect::None; + } else { + self.otp_pending = true; + return AppEffect::AuthenticateOtp(ui_request); + } + } + Action::OtpInsert | Action::OtpAppend | Action::OtpValidate => { + let (kind, entry) = match action { + Action::OtpInsert => (OtpFormKind::Insert, None), + Action::OtpAppend => (OtpFormKind::Append, self.selected_entry.clone()), + Action::OtpValidate => (OtpFormKind::Validate, None), + _ => unreachable!(), + }; + self.workflow = Some(WorkflowForm::otp(kind, entry, false)); + self.transition(Transition::OpenDialog); + self.status = "OTP form: Tab navigates, C-s submits, Esc cancels".to_owned(); + return AppEffect::None; + } } AppEffect::None } @@ -1112,6 +1380,49 @@ impl App { ); AppEffect::None } + CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Code(_))) => { + self.transition(Transition::Dismiss); + self.hotp_confirmation = Some(OtpUiRequest { + request, + confirmed_hotp: false, + field: None, + }); + self.transition(Transition::OpenDialog); + self.status = + "Confirm OTP generation; HOTP advances and commits its counter".to_owned(); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Uri(_))) => { + self.transition(Transition::Dismiss); + self.otp_pending = true; + AppEffect::AuthenticateOtp(OtpUiRequest { + request, + confirmed_hotp: false, + field: None, + }) + } + CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Insert(request))) => { + self.transition(Transition::Dismiss); + self.workflow = Some(WorkflowForm::otp( + OtpFormKind::Insert, + request.entry, + request.force, + )); + self.transition(Transition::OpenDialog); + self.status = "OTP insert form: enter a URI, confirm, then C-s".to_owned(); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Append(request))) => { + self.transition(Transition::Dismiss); + self.workflow = Some(WorkflowForm::otp( + OtpFormKind::Append, + Some(request.entry), + request.force, + )); + self.transition(Transition::OpenDialog); + self.status = "OTP append form: enter a URI, confirm, then C-s".to_owned(); + AppEffect::None + } CommandInvocation::Storage(CommandRequest::Show(request)) if request.presentation == Presentation::Terminal => { @@ -1496,6 +1807,11 @@ impl App { pub fn authentication_failed(&mut self, message: String) { self.git_pending = false; + self.otp_pending = false; + self.otp_display = None; + self.qr_popup = None; + self.uri_popup = None; + self.clipboard_request = None; self.authentication_pending = None; self.selected_entry = None; self.viewer = None; @@ -1524,6 +1840,11 @@ impl App { self.authentication_pending = None; self.git_pending = false; self.git_view = None; + self.otp_pending = false; + self.otp_display = None; + self.qr_popup = None; + self.uri_popup = None; + self.clipboard_request = None; self.remaining_lease = None; self.status = if discarded_edit { format!("Locked: {reason}; unsaved edits were discarded") @@ -1600,6 +1921,12 @@ impl App { self.workflow = None; self.workflow_pending = false; self.grep_view = None; + self.otp_pending = false; + self.otp_display = None; + self.hotp_confirmation = None; + self.qr_popup = None; + self.uri_popup = None; + self.clipboard_request = None; self.status = "Locked".to_owned(); } else if current == Mode::Locked { self.status = "Authentication required".to_owned(); @@ -2069,4 +2396,40 @@ mod tests { assert!(app.command_line().history().is_empty()); assert!(!app.status().contains(secret)); } + + #[test] + fn hotp_requires_confirmation_and_uri_forms_remain_masked() { + let mut app = App::new(); + assert!(matches!( + enter_command(&mut app, "otp code otp/hotp"), + AppEffect::None + )); + assert_eq!(app.mode(), Mode::Dialog); + assert!(app.hotp_confirmation()); + assert!(matches!( + app.dispatch(Action::ConfirmDiscard), + AppEffect::AuthenticateOtp(OtpUiRequest { + confirmed_hotp: true, + .. + }) + )); + + app.workflow_pending = false; + app.mode = Mode::Browser; + app.dispatch(Action::OtpInsert); + let secret = "otpauth://totp/test?secret=NEVER-RENDER"; + app.workflow.as_mut().expect("OTP form").handle_key( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + ); + for character in secret.chars() { + app.workflow.as_mut().expect("OTP form").handle_key( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + let rows = app.workflow().expect("OTP form").rows().join("\n"); + assert!(rows.contains("••••••••")); + assert!(!rows.contains("NEVER-RENDER")); + } } diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index bfeb388..5f76177 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -46,6 +46,12 @@ impl ClipboardCancellations { fn register(&mut self, cancellation: Sender<()>) { self.0.push(cancellation); } + + fn cancel_all(&mut self) { + for cancellation in self.0.drain(..) { + let _ignored = cancellation.send(()); + } + } } impl Drop for ClipboardCancellations { @@ -75,6 +81,16 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { for result in executor.drain() { app.apply_result(result); } + if let Some(value) = app.take_clipboard_request() { + apply_app_effect( + &mut app, + AppEffect::CopyFocused(value), + &mut authentication, + &executor, + &mut git_control, + &mut clipboard_cancellations, + ); + } if !app.git_pending() { git_control = None; } @@ -98,6 +114,34 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { terminal.draw(|frame| ui::draw(frame, &app))?; if !event::poll(TICK_INTERVAL)? { app.tick(); + if let Some((entry, field)) = app.begin_totp_refresh() { + if let (Some(handle), Some(config)) = ( + authentication + .as_ref() + .and_then(AuthenticationCoordinator::handle), + app.config().cloned(), + ) { + let token = app.begin_request(); + executor.submit(token, move || { + execute_otp_ui( + &config, + crate::app::OtpUiRequest { + request: ironstorage::command::OtpRequest::Code( + ironstorage::command::OtpCodeRequest { + entry, + clipboard: false, + }, + ), + confirmed_hotp: false, + field: Some(field), + }, + handle, + ) + }); + } else { + app.authentication_failed("authentication lease expired".to_owned()); + } + } if let Some(coordinator) = authentication.as_mut() { if let Some(event) = coordinator.poll_lease() { apply_authentication_event( @@ -246,6 +290,15 @@ fn apply_app_effect( ); } } + AppEffect::AuthenticateOtp(request) => { + if let Some(coordinator) = authentication.as_mut() { + coordinator.request_otp(request); + } else { + app.authentication_failed( + "operating-system secure storage is unavailable".to_owned(), + ); + } + } AppEffect::CancelGit => { if let Some(control) = git_control.as_ref() { control.cancel(); @@ -264,6 +317,10 @@ fn apply_app_effect( } AppEffect::CopyFocused(value) => { if let Some(config) = app.config().cloned() { + app.report_status(format!( + "Secret copied; cleanup in {}s", + config.clipboard_timeout().duration().as_secs() + )); let (cancel, cancellation) = mpsc::channel(); clipboard_cancellations.register(cancel); let token = app.begin_request(); @@ -306,6 +363,7 @@ fn apply_app_effect( executor.submit(token, move || Ok(save_document(&config, entry, editor))); } AppEffect::ManualLock => { + clipboard_cancellations.cancel_all(); if let Some(coordinator) = authentication.as_mut() && let Err(error) = coordinator.lock() { @@ -352,6 +410,16 @@ fn apply_app_effect( executor.submit(token, move || execute_git(&config, request, None, &control)); } } + AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp( + ironstorage::command::OtpRequest::Validate { uri }, + )) => { + let token = app.begin_request(); + executor.submit(token, move || { + ironstorage::otp::OtpService::validate(&uri) + .map(|()| AsyncPayload::OtpValidated) + .map_err(|error| error.to_string()) + }); + } AppEffect::RunCommand(request) => { app.report_status(format!( "{} is not implemented by this terminal workflow yet", @@ -683,6 +751,78 @@ fn execute_git_resolution( }) } +fn execute_otp_ui( + config: &ironstorage::config::Config, + request: crate::app::OtpUiRequest, + mut provider: ironstorage::authentication::NativeAuthenticationHandle, +) -> Result { + use ironstorage::{ + command::{OtpRequest, OtpUriPresentation}, + otp::{OtpKind, OtpService}, + presentation::QrMatrix, + }; + 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 service = OtpService::new(&repository, &keys); + let field = request.field; + match request.request { + OtpRequest::Code(code_request) => { + let uri = service + .uri(&code_request.entry, &mut provider) + .map_err(|error| error.to_string())?; + if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp { + return Err("HOTP generation requires explicit confirmation".to_owned()); + } + let unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "the system clock is before the Unix epoch".to_owned())? + .as_secs(); + let outcome = service + .code_automatic(&code_request.entry, unix_seconds, None, &mut provider) + .map_err(|error| error.to_string())?; + let remaining_seconds = outcome.remaining_at(unix_seconds); + let counter = outcome.counter(); + let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec()); + let tree = counter.map(|_| load_tree(config)).transpose()?; + Ok(AsyncPayload::OtpCodeFinished { + entry: code_request.entry, + field, + code, + remaining_seconds, + counter, + clipboard: code_request.clipboard, + tree, + }) + } + OtpRequest::Uri(uri_request) => { + let uri = service + .uri(&uri_request.entry, &mut provider) + .map_err(|error| error.to_string())?; + let payload = + ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec()); + let (presentation, qr) = match uri_request.presentation { + OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None), + OtpUriPresentation::Clipboard => { + (crate::app::OtpPresentationTarget::Clipboard, None) + } + OtpUriPresentation::QrCode => ( + crate::app::OtpPresentationTarget::Qr, + Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?), + ), + }; + Ok(AsyncPayload::OtpUriFinished { + entry: uri_request.entry, + presentation, + payload, + qr, + }) + } + _ => Err("this OTP request is not a presentation operation".to_owned()), + } +} + fn load_startup() -> Result { let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let repository = ironstorage::repository::Repository::open(config.vault()) @@ -769,6 +909,16 @@ fn apply_authentication_event( execute_git(&config, request, Some(handle), &control) }); } + AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => { + let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else { + app.authentication_failed( + "authentication completed without an active secure-store lease".to_owned(), + ); + return; + }; + let token = app.begin_request(); + executor.submit(token, move || execute_otp_ui(&config, request, handle)); + } AuthenticationEvent::Failed { workflow, message } => { if workflow { app.workflow_authentication_failed(message); @@ -805,6 +955,7 @@ fn execute_workflow( AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity, }, mutation::TreeMutator, + otp::OtpService, recipient::RecipientPolicyManager, write::VaultWriter, }; @@ -926,6 +1077,53 @@ fn execute_workflow( .map(|selection| (selection.display_path(), selection.is_directory())); (None, format!("Copied {source} to {destination}")) } + WorkflowSubmission::OtpInsert { request, input } => { + let service = OtpService::new(&repository, &keys); + let plan = service + .prepare_insert(&request, input) + .map_err(|error| error.to_string())?; + let path = plan.path().to_string(); + let mut committer = + AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage()) + .map_err(|error| error.to_string())?; + let outcome = service + .finish_insert( + plan, + ironstorage::write::OverwriteDecision::Allow, + ironstorage::write::OverwriteDecision::Allow, + None, + &mut committer, + ) + .map_err(|error| error.to_string())?; + selection = Some((outcome.path().to_string(), false)); + (None, format!("Inserted OTP URI at {}", outcome.path())) + } + WorkflowSubmission::OtpAppend { request, input } => { + let service = OtpService::new(&repository, &keys); + let session = service + .begin_append(&request, provider) + .map_err(|error| error.to_string())?; + let path = session.path().to_string(); + let mut committer = + AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage()) + .map_err(|error| error.to_string())?; + let outcome = service + .finish_append( + session, + input, + ironstorage::write::OverwriteDecision::Allow, + None, + &mut committer, + ) + .map_err(|error| error.to_string())?; + selection = Some((outcome.path().to_string(), false)); + (None, format!("Updated OTP URI at {}", outcome.path())) + } + WorkflowSubmission::OtpValidate { uri } => { + OtpService::validate_input(uri).map_err(|error| error.to_string())?; + refresh_tree = false; + (None, "OTP URI is valid".to_owned()) + } }; let tree = refresh_tree .then(|| { diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 90d8e35..7414496 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -36,6 +36,7 @@ pub enum AuthenticationTarget { Entry(String), Workflow(Box), Git(ironstorage::command::GitRequest), + Otp(crate::app::OtpUiRequest), } struct AuthenticationCompletion { @@ -84,6 +85,10 @@ impl AuthenticationCoordinator { self.request(AuthenticationTarget::Git(request)); } + pub fn request_otp(&mut self, request: crate::app::OtpUiRequest) { + self.request(AuthenticationTarget::Otp(request)); + } + fn request(&mut self, target: AuthenticationTarget) { self.generation = self.generation.wrapping_add(1); let generation = self.generation; diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 870143a..fb39573 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -77,6 +77,43 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { return; } + if let Some(matrix) = app.qr_popup() { + let padded_width = matrix.width() + 8; + let needed_width = + u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX); + let needed_height = u16::try_from( + padded_width + .next_multiple_of(2) + .saturating_div(2) + .saturating_add(2), + ) + .unwrap_or(u16::MAX); + let text = if area.width < needed_width || area.height < needed_height { + format!( + "Terminal too small for OTP QR (need {needed_width}×{needed_height}); resize or Esc to close." + ) + } else { + let rendered = matrix.render_terminal(); + String::from_utf8_lossy(rendered.expose()).into_owned() + }; + frame.render_widget( + Paragraph::new(text) + .block(Block::bordered().title("OTP QR — Esc closes")) + .wrap(Wrap { trim: false }), + area, + ); + return; + } + if let Some(uri) = app.uri_popup() { + frame.render_widget( + Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned()) + .block(Block::bordered().title("OTP URI — Esc closes")) + .wrap(Wrap { trim: false }), + area, + ); + return; + } + if app.mode() == Mode::Help { if let Some(help) = app.command_help() { frame.render_widget( @@ -87,27 +124,32 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { ); return; } - let lines = help_actions(app.help_context_mode()).map(|spec| { - let bindings = spec - .bindings - .iter() - .map(|binding| binding.display) + let entries = help_actions(app.help_context_mode()) + .map(|spec| { + let bindings = spec + .bindings + .iter() + .map(|binding| binding.display) + .collect::>() + .join(", "); + format!( + "{bindings:>10} {:<20} :{:<16} {}", + spec.label, + spec.command, + spec.help() + ) + }) + .collect::>(); + let lines = if area.width >= 120 { + entries + .chunks(2) + .map(|chunk| Line::raw(chunk.join(" "))) .collect::>() - .join(", "); - let mut spans = vec![ - Span::styled(format!("{bindings:>14}"), Style::default().fg(Color::Cyan)), - Span::raw(format!(" {:<24} :{:<18}", spec.label, spec.command)), - ]; - if !spec.help().is_empty() { - spans.push(Span::styled( - spec.help(), - Style::default().fg(Color::Yellow), - )); - } - Line::from(spans) - }); + } else { + entries.into_iter().map(Line::raw).collect::>() + }; frame.render_widget( - Paragraph::new(lines.collect::>()) + Paragraph::new(lines) .block(Block::bordered().title(format!( "Contextual help — {}", mode_title(app.help_context_mode()) @@ -157,7 +199,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { Mode::Viewer => app.viewer().map_or_else( || Paragraph::new(main_text(app)), |viewer| { - Paragraph::new(viewer_lines(viewer)) + Paragraph::new(viewer_lines(viewer, app.otp_display())) .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) }, ), @@ -258,13 +300,19 @@ fn main_text(app: &App) -> String { Mode::Dialog if app.discard_confirmation() => { "Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned() } + Mode::Dialog if app.hotp_confirmation() => { + "Generate this HOTP code? This advances and commits its counter. Press y to continue, n or Esc to cancel.".to_owned() + } Mode::Dialog => "Complete or cancel the active dialog.".to_owned(), Mode::Command => "Enter a command on the bottom line.".to_owned(), Mode::Help | Mode::Locked => String::new(), } } -fn viewer_lines(viewer: &EntryViewer) -> Vec> { +fn viewer_lines<'a>( + viewer: &'a EntryViewer, + otp_display: Option<&'a crate::app::OtpDisplay>, +) -> Vec> { let focused = viewer.focused_index(); if viewer.document().fields().is_empty() { return vec![Line::from("This entry is empty.")]; @@ -323,6 +371,28 @@ fn viewer_lines(viewer: &EntryViewer) -> Vec> { ), Style::default().fg(Color::DarkGray), )); + if focused == Some(index) + && let Some(display) = otp_display + && display + .field() + .is_none_or(|display_field| display_field == field.id()) + { + let code = String::from_utf8_lossy(display.code().expose()); + let validity = display.remaining_seconds().map_or_else( + || { + display + .counter() + .map_or(String::new(), |counter| format!(", counter {counter}")) + }, + |remaining| format!(", {remaining}s remaining"), + ); + spans.push(Span::styled( + format!(" code {code}{validity}"), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } } let line = Line::from(spans); if focused == Some(index) { @@ -856,6 +926,77 @@ mod tests { assert!(!output.contains("JBSWY3DPEHPK3PXP")); } + #[test] + fn otp_code_qr_resize_and_lock_lifecycle_are_secret_safe() { + let mut app = App::new(); + let document = fixture_document("otp/totp"); + let wrong_field = document.fields()[0].id(); + let otp_field = document + .fields() + .iter() + .find(|field| field.metadata().otp().is_some()) + .expect("OTP field") + .id(); + app.open_test_document("otp/totp", document); + app.dispatch(crate::action::Action::FocusNext); + let token = app.begin_request(); + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(crate::app::AsyncPayload::OtpCodeFinished { + entry: "otp/totp".to_owned(), + field: Some(wrong_field), + code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), + remaining_seconds: Some(12), + counter: None, + clipboard: false, + tree: None, + }), + }); + assert!(!render(120, 20, &app).contains("123456")); + let token = app.begin_request(); + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(crate::app::AsyncPayload::OtpCodeFinished { + entry: "otp/totp".to_owned(), + field: Some(otp_field), + code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), + remaining_seconds: Some(12), + counter: None, + clipboard: false, + tree: None, + }), + }); + let code = render(120, 20, &app); + assert!(code.contains("123456")); + assert!(code.contains("12s remaining")); + assert!(!code.contains("JBSWY3DPEHPK3PXP")); + + let payload = ironstorage::repository::SecretBytes::new( + b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(), + ); + let qr = ironstorage::presentation::QrMatrix::encode(&payload).expect("QR"); + let token = app.begin_request(); + app.apply_result(crate::app::AsyncResult { + token, + payload: Ok(crate::app::AsyncPayload::OtpUriFinished { + entry: "otp/totp".to_owned(), + presentation: crate::app::OtpPresentationTarget::Qr, + payload, + qr: Some(qr), + }), + }); + let resized = render(40, 8, &app); + assert!(resized.contains("too small for OTP QR")); + assert!(!resized.contains("NEVER-RENDER")); + assert!(matches!( + app.dispatch(crate::action::Action::Lock), + crate::app::AppEffect::ManualLock + )); + let locked = render(120, 20, &app); + assert!(!locked.contains("123456")); + assert!(!locked.contains("NEVER-RENDER")); + } + #[test] fn closing_a_document_preserves_the_sidebar_selection() { let mut app = App::new(); diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 3be2f48..53dce82 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -6,9 +6,11 @@ use crossterm::event::{KeyCode, KeyModifiers}; use ironstorage::{ command::{ CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput, - InsertRequest, MoveRequest, RemoveRequest, + InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest, + RemoveRequest, }, crypto::KeyInfo, + otp::OtpInput, write::{InsertContent, OverwriteDecision}, }; use zeroize::Zeroize; @@ -135,6 +137,23 @@ pub struct TransferForm { focus: usize, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpFormKind { + Insert, + Append, + Validate, +} + +#[derive(Debug)] +pub struct OtpForm { + kind: OtpFormKind, + entry: String, + uri: SecretText, + force: bool, + confirmed: bool, + focus: usize, +} + #[derive(Debug)] pub enum WorkflowForm { Init(InitForm), @@ -144,6 +163,7 @@ pub enum WorkflowForm { Remove(RemoveForm), Move(TransferForm), Copy(TransferForm), + Otp(OtpForm), } #[derive(Debug)] @@ -168,6 +188,17 @@ pub enum WorkflowSubmission { request: CopyRequest, overwrite: OverwriteDecision, }, + OtpInsert { + request: OtpInsertRequest, + input: OtpInput, + }, + OtpAppend { + request: OtpAppendRequest, + input: OtpInput, + }, + OtpValidate { + uri: OtpInput, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -179,6 +210,16 @@ pub enum WorkflowInput { } impl WorkflowForm { + pub fn otp(kind: OtpFormKind, entry: Option, force: bool) -> Self { + Self::Otp(OtpForm { + kind, + entry: entry.unwrap_or_default(), + uri: SecretText::default(), + force, + confirmed: false, + focus: 0, + }) + } pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option) -> Self { let request = request.unwrap_or_else(|| InitRequest { path: None, @@ -328,6 +369,11 @@ impl WorkflowForm { Self::Remove(_) => "Remove entry or folder", Self::Move(_) => "Move or rename", Self::Copy(_) => "Copy entry or folder", + Self::Otp(form) => match form.kind { + OtpFormKind::Insert => "Insert OTP URI", + OtpFormKind::Append => "Append OTP URI", + OtpFormKind::Validate => "Validate OTP URI", + }, } } @@ -424,6 +470,48 @@ impl WorkflowForm { yes_no(form.overwrite), ), ], + Self::Otp(form) => { + vec![ + row( + form.focus == 0, + "Entry", + if form.kind == OtpFormKind::Validate { + "(validation only)" + } else if form.entry.is_empty() { + "(derive from URI)" + } else { + &form.entry + }, + ), + row( + form.focus == 1, + "OTP URI", + if form.uri.is_empty() { + "(empty)" + } else { + "••••••••" + }, + ), + row( + form.focus == 2, + "Force replacement", + if form.kind == OtpFormKind::Validate { + "(not used)" + } else { + yes_no(form.force) + }, + ), + row( + form.focus == 3, + "Confirm write", + if form.kind == OtpFormKind::Validate { + "(not required)" + } else { + yes_no(form.confirmed) + }, + ), + ] + } } } @@ -573,6 +661,47 @@ impl WorkflowForm { overwrite, }) } + Self::Otp(form) => { + if form.uri.is_empty() { + return Err("OTP URI is required".to_owned()); + } + let input = OtpInput::line(form.uri.bytes()).map_err(|error| error.to_string())?; + match form.kind { + OtpFormKind::Validate => Ok(WorkflowSubmission::OtpValidate { uri: input }), + OtpFormKind::Insert => { + if !form.confirmed { + return Err("Explicitly confirm the OTP write".to_owned()); + } + Ok(WorkflowSubmission::OtpInsert { + request: OtpInsertRequest { + entry: (!form.entry.trim().is_empty()) + .then(|| form.entry.trim().to_owned()), + force: form.force, + echo: false, + source: OtpInputSource::Uri, + }, + input, + }) + } + OtpFormKind::Append => { + if form.entry.trim().is_empty() { + return Err("Entry path is required".to_owned()); + } + if !form.confirmed { + return Err("Explicitly confirm the OTP write".to_owned()); + } + Ok(WorkflowSubmission::OtpAppend { + request: OtpAppendRequest { + entry: form.entry.trim().to_owned(), + force: form.force, + echo: false, + source: OtpInputSource::Uri, + }, + input, + }) + } + } + } } } @@ -582,6 +711,7 @@ impl WorkflowForm { Self::Insert(_) | Self::Generate(_) => 5, Self::Grep(_) => 5, Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4, + Self::Otp(_) => 4, } } @@ -594,6 +724,7 @@ impl WorkflowForm { Self::Grep(form) => &mut form.focus, Self::Remove(form) => &mut form.focus, Self::Move(form) | Self::Copy(form) => &mut form.focus, + Self::Otp(form) => &mut form.focus, }; *focus = if forward { (*focus + 1) % count @@ -631,6 +762,8 @@ impl WorkflowForm { form.destination_directory ^= true } Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true, + Self::Otp(form) if form.focus == 2 => form.force ^= true, + Self::Otp(form) if form.focus == 3 => form.confirmed ^= true, _ => self.character(' '), } } @@ -674,6 +807,12 @@ impl WorkflowForm { Self::Move(form) | Self::Copy(form) if form.focus == 1 => { form.destination.pop(); } + Self::Otp(form) if form.focus == 0 => { + form.entry.pop(); + } + Self::Otp(form) if form.focus == 1 => { + form.uri.pop(); + } _ => {} } } @@ -702,6 +841,8 @@ impl WorkflowForm { Self::Move(form) | Self::Copy(form) if form.focus == 1 => { form.destination.push(character) } + Self::Otp(form) if form.focus == 0 => form.entry.push(character), + Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character), _ => {} } } diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index 13d2fe3..5bf44c6 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -427,6 +427,7 @@ impl OtpWriteOutcome { pub struct OtpCodeOutcome { code: SecretBytes, counter: Option, + valid_until: Option, } impl OtpCodeOutcome { @@ -437,6 +438,15 @@ impl OtpCodeOutcome { pub fn counter(&self) -> Option { self.counter } + + pub fn valid_until(&self) -> Option { + self.valid_until + } + + pub fn remaining_at(&self, unix_seconds: u64) -> Option { + self.valid_until + .map(|valid_until| valid_until.saturating_sub(unix_seconds)) + } } impl fmt::Debug for OtpCodeOutcome { @@ -445,6 +455,7 @@ impl fmt::Debug for OtpCodeOutcome { .debug_struct("OtpCodeOutcome") .field("code", &"[REDACTED]") .field("counter", &self.counter) + .field("valid_until", &self.valid_until) .finish() } } @@ -463,6 +474,10 @@ impl<'a> OtpService<'a> { OtpUri::parse_str(encoded).map(|_| ()) } + pub fn validate_input(input: OtpInput) -> Result<(), OtpError> { + OtpUri::parse(input.into_secret()).map(|_| ()) + } + pub fn prepare_insert( &self, request: &OtpInsertRequest, @@ -639,9 +654,16 @@ impl<'a> OtpService<'a> { ) -> Result { let (path, original, plaintext, range, uri) = self.load_code_entry(entry, provider)?; if uri.kind() == OtpKind::Totp { + let period = uri.period().ok_or(OtpError::NotTotp)?; return Ok(OtpCodeOutcome { code: uri.code_at(unix_seconds)?, counter: None, + valid_until: Some( + (unix_seconds / period) + .checked_add(1) + .and_then(|counter| counter.checked_mul(period)) + .ok_or(OtpError::CounterOverflow)?, + ), }); } let entry = path.to_string(); @@ -692,6 +714,13 @@ impl<'a> OtpService<'a> { OtpKind::Totp => Ok(OtpCodeOutcome { code: uri.code_at(unix_seconds)?, counter: None, + valid_until: Some({ + let period = uri.period().ok_or(OtpError::NotTotp)?; + (unix_seconds / period) + .checked_add(1) + .and_then(|counter| counter.checked_mul(period)) + .ok_or(OtpError::CounterOverflow)? + }), }), OtpKind::Hotp => { let (counter, incremented) = uri.incremented_hotp()?; @@ -714,6 +743,7 @@ impl<'a> OtpService<'a> { Ok(OtpCodeOutcome { code, counter: Some(counter), + valid_until: None, }) } } diff --git a/crates/storage/tests/otp.rs b/crates/storage/tests/otp.rs index 9399afc..8a468f9 100644 --- a/crates/storage/tests/otp.rs +++ b/crates/storage/tests/otp.rs @@ -477,6 +477,8 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes let totp_before = repository.read_entry(&totp_path)?; let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?; assert_eq!(totp.counter(), None); + assert_eq!(totp.valid_until(), Some(60)); + assert_eq!(totp.remaining_at(59), Some(1)); assert_eq!(repository.read_entry(&totp_path)?, totp_before); let git = GitRepository::open(&repository, identity.clone())?; assert_eq!(git.log(None)?.len(), initial_commits); @@ -484,6 +486,7 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes let hotp = service.code_automatic("otp/hotp", 0, None, &mut provider)?; assert_eq!(hotp.counter(), Some(1)); + assert_eq!(hotp.valid_until(), None); assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1)); let git = GitRepository::open(&repository, identity)?; assert_eq!(git.log(None)?.len(), initial_commits + 1);