diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 14c61bc..904f69d 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -27,6 +27,12 @@ pub enum Action { PreviousMatch, FocusNext, FocusPrevious, + Reveal, + Hide, + Copy, + ScrollDown, + ScrollUp, + CloseEntry, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -222,18 +228,68 @@ pub static ACTIONS: &[ActionSpec] = &[ }, ActionSpec { action: Action::FocusNext, - label: "next pane", + label: "next field/pane", command: "focus-next", bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")), modes: UNLOCKED, }, ActionSpec { action: Action::FocusPrevious, - label: "previous pane", + label: "previous field/pane", command: "focus-previous", bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")), modes: UNLOCKED, }, + ActionSpec { + action: Action::Reveal, + label: "reveal field", + command: "reveal", + bindings: keys!((KeyCode::Char('v'), KeyModifiers::NONE, "v")), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::Hide, + label: "hide field", + command: "hide", + bindings: keys!((KeyCode::Char('V'), KeyModifiers::SHIFT, "V")), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::Copy, + label: "copy field", + command: "copy", + bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::ScrollDown, + label: "scroll down", + command: "scroll-down", + bindings: keys!( + (KeyCode::Char('j'), KeyModifiers::NONE, "j"), + (KeyCode::Down, KeyModifiers::NONE, "↓"), + (KeyCode::PageDown, KeyModifiers::NONE, "PgDn"), + ), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::ScrollUp, + label: "scroll up", + command: "scroll-up", + bindings: keys!( + (KeyCode::Char('k'), KeyModifiers::NONE, "k"), + (KeyCode::Up, KeyModifiers::NONE, "↑"), + (KeyCode::PageUp, KeyModifiers::NONE, "PgUp"), + ), + modes: &[Mode::Viewer], + }, + ActionSpec { + action: Action::CloseEntry, + label: "close entry", + command: "close-entry", + bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), + modes: &[Mode::Viewer], + }, ]; 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 eaafe87..20f0d92 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -5,12 +5,16 @@ use std::collections::BTreeSet; use ironstorage::{ config::Config, crypto::KeyInfo, + document::EntryDocument, + presentation::ClipboardDisposition, read::{FindResults, TreeModel}, + repository::SecretBytes, }; use crate::{ action::Action, sidebar::{Sidebar, SidebarIntent}, + viewer::EntryViewer, }; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -56,14 +60,22 @@ pub struct StartupData { pub key: KeyInfo, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AsyncPayload { Startup(Box), Refreshed(TreeModel), - Filtered { query: String, results: FindResults }, + Filtered { + query: String, + results: FindResults, + }, + DocumentLoaded { + entry: String, + document: Box, + }, + ClipboardFinished(ClipboardDisposition), } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub struct AsyncResult { pub token: RequestToken, pub payload: Result, @@ -75,11 +87,12 @@ pub enum ResultDisposition { Stale, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AppEffect { None, RefreshTree, AuthenticateEntry(String), + CopyFocused(SecretBytes), ManualLock, } @@ -93,6 +106,7 @@ pub struct App { default_key: Option, sidebar: Sidebar, selected_entry: Option, + viewer: Option, authentication_pending: Option, remaining_lease: Option, terminal_size: (u16, u16), @@ -120,6 +134,7 @@ impl App { default_key: None, sidebar: Sidebar::default(), selected_entry: None, + viewer: None, authentication_pending: None, remaining_lease: None, terminal_size: (0, 0), @@ -159,6 +174,10 @@ impl App { self.selected_entry.as_deref() } + pub fn viewer(&self) -> Option<&EntryViewer> { + self.viewer.as_ref() + } + pub fn default_key(&self) -> Option<&KeyInfo> { self.default_key.as_ref() } @@ -239,7 +258,34 @@ impl App { } self.status = format!("Filter: {query} ({} matches)", results.matches().len()); } - Err(error) => self.status = error, + Ok(AsyncPayload::DocumentLoaded { entry, document }) => { + if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) { + return ResultDisposition::Stale; + } + let field_count = document.fields().len(); + self.viewer = Some(EntryViewer::new(*document)); + self.focus = PaneFocus::Main; + self.status = format!("Opened {entry} ({field_count} fields)"); + } + Ok(AsyncPayload::ClipboardFinished(disposition)) => { + self.status = match disposition { + ClipboardDisposition::RestoredPrevious => { + "Clipboard restored to its previous value".to_owned() + } + ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(), + ClipboardDisposition::PreservedNewer => { + "Clipboard changed; the newer value was preserved".to_owned() + } + }; + } + Err(error) => { + self.status = error; + if self.mode == Mode::Viewer && self.viewer.is_none() { + self.mode = Mode::Browser; + self.focus = PaneFocus::Sidebar; + self.selected_entry = None; + } + } } ResultDisposition::Applied } @@ -295,12 +341,68 @@ impl App { Action::Filter => self.sidebar.begin_filter(), Action::NextMatch => self.sidebar.next_match(), Action::PreviousMatch => self.sidebar.previous_match(), + Action::FocusNext if self.mode == Mode::Viewer => { + if let Some(viewer) = self.viewer.as_mut() { + viewer.focus_next(); + } + } + Action::FocusPrevious if self.mode == Mode::Viewer => { + if let Some(viewer) = self.viewer.as_mut() { + viewer.focus_previous(); + } + } Action::FocusNext | Action::FocusPrevious => { self.focus = match self.focus { PaneFocus::Sidebar => PaneFocus::Main, PaneFocus::Main => PaneFocus::Sidebar, }; } + Action::Reveal => { + if self + .viewer + .as_mut() + .is_some_and(EntryViewer::reveal_focused) + { + self.status = "Focused sensitive field revealed".to_owned(); + } + } + Action::Hide => { + if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) { + self.status = "Sensitive field hidden".to_owned(); + } + } + Action::Copy => { + if let Some(viewer) = self.viewer.as_ref() { + match viewer.copy_focused() { + Ok(value) => { + self.status = self.config.as_ref().map_or_else( + || "Copying focused field…".to_owned(), + |config| { + format!( + "Copied focused field; cleanup in {}s", + config.clipboard_timeout().duration().as_secs() + ) + }, + ); + return AppEffect::CopyFocused(value); + } + Err(error) => self.status = error.to_string(), + } + } + } + Action::ScrollDown => { + if let Some(viewer) = self.viewer.as_mut() { + viewer.scroll_down(5); + } + } + Action::ScrollUp => { + if let Some(viewer) = self.viewer.as_mut() { + viewer.scroll_up(5); + } + } + Action::CloseEntry => { + self.transition(Transition::CloseEntry); + } Action::Quit => {} } AppEffect::None @@ -312,13 +414,19 @@ impl App { } self.authentication_pending = None; self.selected_entry = Some(entry); - self.status = "Authenticated".to_owned(); - self.transition(Transition::OpenEntry) + self.viewer = None; + self.status = "Authenticated; loading structured entry…".to_owned(); + let transitioned = self.transition(Transition::OpenEntry); + if transitioned { + self.focus = PaneFocus::Main; + } + transitioned } pub fn authentication_failed(&mut self, message: String) { self.authentication_pending = None; self.selected_entry = None; + self.viewer = None; self.status = message; if self.mode != Mode::Browser { self.mode = Mode::Browser; @@ -371,16 +479,22 @@ impl App { return false; }; if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) { + if let Some(viewer) = self.viewer.as_mut() { + viewer.hide_revealed(); + } self.suspended_mode = Some(current); } else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) { self.selected_entry = None; + self.viewer = None; self.authentication_pending = None; self.remaining_lease = None; + self.focus = PaneFocus::Sidebar; } else if destination == Mode::Locked { self.suspended_mode = None; self.focus = PaneFocus::Sidebar; self.invalidate_requests(); self.selected_entry = None; + self.viewer = None; self.status = "Locked".to_owned(); } else if current == Mode::Locked { self.status = "Authentication required".to_owned(); @@ -393,6 +507,15 @@ impl App { self.generation = self.generation.wrapping_add(1); self.pending.clear(); } + + #[cfg(test)] + pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) { + self.mode = Mode::Viewer; + self.focus = PaneFocus::Main; + self.selected_entry = Some(entry.to_owned()); + self.viewer = Some(EntryViewer::new(document)); + self.status = format!("Opened {entry}"); + } } #[cfg(test)] @@ -452,8 +575,14 @@ mod tests { token, payload: Err("completed".to_owned()), }; - assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied); - assert_eq!(app.apply_result(result), ResultDisposition::Stale); + assert_eq!(app.apply_result(result), ResultDisposition::Applied); + assert_eq!( + app.apply_result(AsyncResult { + token, + payload: Err("duplicate".to_owned()), + }), + ResultDisposition::Stale + ); let stale = app.begin_request(); assert!(app.transition(Transition::Lock)); diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 56d1629..2aef2d8 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -9,8 +9,13 @@ pub mod runtime; pub mod sidebar; pub mod terminal; pub mod ui; +pub mod viewer; -use std::{io, time::Duration}; +use std::{ + io, + sync::mpsc::{self, Sender}, + time::Duration, +}; use crossterm::event::{self, Event, KeyEventKind}; use ratatui::DefaultTerminal; @@ -23,11 +28,29 @@ use crate::{ const TICK_INTERVAL: Duration = Duration::from_millis(250); +#[derive(Default)] +struct ClipboardCancellations(Vec>); + +impl ClipboardCancellations { + fn register(&mut self, cancellation: Sender<()>) { + self.0.push(cancellation); + } +} + +impl Drop for ClipboardCancellations { + fn drop(&mut self) { + for cancellation in self.0.drain(..) { + let _ignored = cancellation.send(()); + } + } +} + /// Run the interactive event loop. Polling keeps input responsive while storage /// work runs on the executor and periodic repaints are pending. pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut app = App::new(); let executor = AsyncExecutor::new(); + let mut clipboard_cancellations = ClipboardCancellations::default(); let mut authentication = None; let mut authentication_initialized = false; let startup = app.begin_latest_request(); @@ -51,7 +74,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.completion() { - apply_authentication_event(&mut app, event); + apply_authentication_event(&mut app, coordinator, &executor, event); } let size = terminal.size()?; @@ -61,7 +84,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { app.tick(); if let Some(coordinator) = authentication.as_mut() { if let Some(event) = coordinator.poll_lease() { - apply_authentication_event(&mut app, event); + apply_authentication_event(&mut app, coordinator, &executor, event); } app.update_remaining_lease(coordinator.remaining_time()); } @@ -73,7 +96,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.touch_user_activity() { - apply_authentication_event(&mut app, event); + apply_authentication_event(&mut app, coordinator, &executor, event); } if app.sidebar().is_editing_filter() { if handle_filter_key(&mut app, key.code) { @@ -98,6 +121,33 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { ); } } + AppEffect::CopyFocused(value) => { + if let Some(config) = app.config().cloned() { + let (cancel, cancellation) = mpsc::channel(); + clipboard_cancellations.register(cancel); + let token = app.begin_request(); + executor.submit(token, move || { + let mut clipboard = + ironstorage::presentation::NativeClipboardManager::system( + config.clipboard_timeout(), + ) + .map_err(|error| error.to_string())?; + clipboard + .copy_with(&value, |duration| { + match cancellation.recv_timeout(duration) { + Err(mpsc::RecvTimeoutError::Timeout) => { + ironstorage::presentation::ClipboardWait::Elapsed + } + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { + ironstorage::presentation::ClipboardWait::Cancelled + } + } + }) + .map(AsyncPayload::ClipboardFinished) + .map_err(|error| error.to_string()) + }); + } + } AppEffect::ManualLock => { if let Some(coordinator) = authentication.as_mut() && let Err(error) = coordinator.lock() @@ -144,16 +194,52 @@ fn load_startup() -> Result { Ok(StartupData { config, tree, key }) } -fn apply_authentication_event(app: &mut App, event: AuthenticationEvent) { +fn apply_authentication_event( + app: &mut App, + coordinator: &AuthenticationCoordinator, + executor: &AsyncExecutor, + event: AuthenticationEvent, +) { match event { AuthenticationEvent::Granted(entry) => { - app.authentication_granted(entry); + if !app.authentication_granted(entry.clone()) { + return; + } + 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_latest_request(); + executor.submit(token, move || { + load_document(&config, &entry, handle).map(|document| { + AsyncPayload::DocumentLoaded { + entry, + document: Box::new(document), + } + }) + }); } AuthenticationEvent::Failed(error) => app.authentication_failed(error), AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"), } } +fn load_document( + config: &ironstorage::config::Config, + entry: &str, + mut handle: ironstorage::authentication::NativeAuthenticationHandle, +) -> Result { + 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())?; + ironstorage::document::EntryDocumentService::new(&repository, &keys) + .open(entry, &mut handle) + .map_err(|error| error.to_string()) +} + fn load_tree(config: &ironstorage::config::Config) -> Result { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index e392085..4eeb2a6 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -1,7 +1,10 @@ //! Small in-process executor for storage calls. It never launches a process. use std::{ - sync::mpsc::{self, Receiver, Sender}, + sync::{ + Mutex, + mpsc::{self, Receiver, Sender}, + }, thread, time::Duration, }; @@ -17,6 +20,7 @@ use crate::app::{AsyncPayload, AsyncResult, RequestToken}; pub struct AsyncExecutor { sender: Sender, receiver: Receiver, + tasks: Mutex>>, } #[derive(Debug, Eq, PartialEq)] @@ -147,7 +151,11 @@ impl Default for AsyncExecutor { impl AsyncExecutor { pub fn new() -> Self { let (sender, receiver) = mpsc::channel(); - Self { sender, receiver } + Self { + sender, + receiver, + tasks: Mutex::new(Vec::new()), + } } pub fn submit(&self, token: RequestToken, work: F) @@ -155,12 +163,16 @@ impl AsyncExecutor { F: FnOnce() -> Result + Send + 'static, { let sender = self.sender.clone(); - thread::spawn(move || { + let task = thread::spawn(move || { let _ignored = sender.send(AsyncResult { token, payload: work(), }); }); + self.tasks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(task); } pub fn drain(&self) -> impl Iterator + '_ { @@ -168,6 +180,18 @@ impl AsyncExecutor { } } +impl Drop for AsyncExecutor { + fn drop(&mut self) { + let tasks = self + .tasks + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for task in tasks.drain(..) { + let _ignored = task.join(); + } + } +} + #[cfg(test)] mod tests { use std::time::Duration; diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index daf21d2..10e5e0b 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -11,6 +11,7 @@ use ratatui::{ use crate::{ action::available_actions, app::{App, Mode, PaneFocus}, + viewer::EntryViewer, }; const MINIMUM_WIDTH: u16 = 40; @@ -124,13 +125,23 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { .wrap(Wrap { trim: false }), panes[0], ); - frame.render_widget( + let main = if app.mode() == Mode::Viewer { + app.viewer().map_or_else( + || Paragraph::new(main_text(app)), + |viewer| { + Paragraph::new(viewer_lines(viewer)) + .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) + }, + ) + } else { Paragraph::new(main_text(app)) - .block(pane_block( - mode_title(app.mode()), - app.focus() == PaneFocus::Main, - )) - .wrap(Wrap { trim: true }), + }; + frame.render_widget( + main.block(pane_block( + mode_title(app.mode()), + app.focus() == PaneFocus::Main, + )) + .wrap(Wrap { trim: false }), panes[1], ); } @@ -208,6 +219,76 @@ fn main_text(app: &App) -> String { } } +fn viewer_lines(viewer: &EntryViewer) -> Vec> { + let focused = viewer.focused_index(); + if viewer.document().fields().is_empty() { + return vec![Line::from("This entry is empty.")]; + } + + viewer + .document() + .fields() + .iter() + .enumerate() + .map(|(index, field)| { + let metadata = field.metadata(); + let label = metadata.name().map_or_else( + || match metadata.kind() { + ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1), + ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1), + kind => format!("{kind:?}").to_ascii_lowercase(), + }, + str::to_owned, + ); + let value = if metadata.sensitivity() + == ironstorage::document::EntrySensitivity::Sensitive + && !viewer.is_revealed(field.id()) + { + Span::styled("••••••••", Style::default().fg(Color::DarkGray)) + } else if field.value().is_empty() { + Span::styled("(empty)", Style::default().fg(Color::DarkGray)) + } else { + match std::str::from_utf8(field.value()) { + Ok(value) => Span::raw(value), + Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)), + } + }; + let mut spans = vec![ + Span::styled( + format!("{label}: "), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + value, + ]; + if let Some(otp) = metadata.otp() { + let timing = otp.period().map_or_else( + || format!("counter {}", otp.counter().unwrap_or_default()), + |period| format!("period {period}s"), + ); + spans.push(Span::styled( + format!( + " [{:?}, {}, {:?}, {}, {} digits, {timing}]", + otp.kind(), + otp.issuer().unwrap_or("no issuer"), + otp.algorithm(), + otp.account(), + otp.digits(), + ), + Style::default().fg(Color::DarkGray), + )); + } + let line = Line::from(spans); + if focused == Some(index) { + line.style(Style::default().bg(Color::Blue).fg(Color::White)) + } else { + line + } + }) + .collect() +} + fn mode_title(mode: Mode) -> &'static str { match mode { Mode::Browser => "Browser", @@ -264,6 +345,7 @@ mod tests { use super::*; use crate::app::Transition; use crate::sidebar::TestTreeNode; + use crate::viewer::test_support::fixture_document; fn render(width: u16, height: u16, app: &App) -> String { let backend = TestBackend::new(width, height); @@ -384,4 +466,77 @@ mod tests { let output = render(100, 20, &app); assert!(output.contains("locks in 30s")); } + + #[test] + fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() { + let mut app = App::new(); + app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡")); + for width in [60, 100, 140] { + let output = render(width, 20, &app); + assert!(output.contains("password: ••••••••")); + assert!(output.contains("login:")); + assert!(output.contains('用')); + assert!(output.contains("@example.test")); + assert!(output.contains("notes: ••••••••")); + assert!(!output.contains("pässwörd-猫")); + } + assert!(!format!("{app:?}").contains("pässwörd-猫")); + } + + #[test] + fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::Reveal); + let revealed = render(100, 20, &app); + assert!(revealed.contains("correct horse fixture")); + + app.dispatch(crate::action::Action::FocusNext); + let hidden = render(100, 20, &app); + assert!(!hidden.contains("correct horse fixture")); + + app.dispatch(crate::action::Action::FocusPrevious); + app.dispatch(crate::action::Action::Reveal); + app.dispatch(crate::action::Action::Help); + app.dispatch(crate::action::Action::Cancel); + let after_overlay = render(100, 20, &app); + assert!(!after_overlay.contains("correct horse fixture")); + + app.dispatch(crate::action::Action::Reveal); + app.forced_relock("test lock"); + let locked = render(100, 20, &app); + assert!(!locked.contains("correct horse fixture")); + assert!(locked.contains("locked")); + } + + #[test] + fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() { + let mut app = App::new(); + app.open_test_document("otp/totp", fixture_document("otp/totp")); + app.dispatch(crate::action::Action::FocusNext); + let output = render(120, 20, &app); + assert!(output.contains("Totp")); + assert!(output.contains("IronStorage")); + assert!(output.contains("alice@example.test")); + assert!(output.contains("period 30s")); + assert!(!output.contains("JBSWY3DPEHPK3PXP")); + } + + #[test] + fn closing_a_document_preserves_the_sidebar_selection() { + let mut app = App::new(); + app.sidebar_mut().replace_test_tree(vec![TestTreeNode { + path: "email/personal".to_owned(), + name: "personal".to_owned(), + directory: false, + indicators: ironstorage::read::TreeNodeIndicators::default(), + children: vec![], + }]); + let selected = app.sidebar().selected().cloned(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::CloseEntry); + assert_eq!(app.mode(), Mode::Browser); + assert_eq!(app.sidebar().selected(), selected.as_ref()); + assert!(app.viewer().is_none()); + } } diff --git a/apps/tui/src/viewer.rs b/apps/tui/src/viewer.rs new file mode 100644 index 0000000..338981c --- /dev/null +++ b/apps/tui/src/viewer.rs @@ -0,0 +1,194 @@ +//! Secret-aware presentation state for one storage-owned entry document. + +use std::fmt; + +use ironstorage::{ + document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity}, + repository::SecretBytes, +}; + +/// Focus, masking, and scrolling state for an authenticated document. +/// +/// The document remains the source of field order, labels, kinds, sensitivity, +/// and values. This type only tracks transient presentation choices. +pub struct EntryViewer { + document: EntryDocument, + focused: usize, + revealed: Option, + scroll: usize, +} + +impl EntryViewer { + pub fn new(document: EntryDocument) -> Self { + Self { + document, + focused: 0, + revealed: None, + scroll: 0, + } + } + + pub fn document(&self) -> &EntryDocument { + &self.document + } + + pub fn focused_index(&self) -> Option { + (!self.document.fields().is_empty()).then_some(self.focused) + } + + pub fn focused_field(&self) -> Option<&EntryField> { + self.document.fields().get(self.focused) + } + + pub fn is_revealed(&self, id: EntryFieldId) -> bool { + self.revealed == Some(id) + } + + pub fn focus_next(&mut self) { + self.hide_revealed(); + if !self.document.fields().is_empty() { + self.focused = (self.focused + 1) % self.document.fields().len(); + self.scroll = self.focused; + } + } + + pub fn focus_previous(&mut self) { + self.hide_revealed(); + if !self.document.fields().is_empty() { + self.focused = self + .focused + .checked_sub(1) + .unwrap_or(self.document.fields().len() - 1); + self.scroll = self.focused; + } + } + + pub fn reveal_focused(&mut self) -> bool { + let Some(field) = self.focused_field() else { + return false; + }; + if field.metadata().sensitivity() != EntrySensitivity::Sensitive { + return false; + } + self.revealed = Some(field.id()); + true + } + + pub fn hide_revealed(&mut self) -> bool { + self.revealed.take().is_some() + } + + pub fn copy_focused(&self) -> Result { + let field = self.focused_field().ok_or(DocumentError::InvalidIndex { + index: self.focused, + })?; + self.document.copy_field_value(field.id()) + } + + pub fn scroll(&self) -> usize { + self.scroll + } + + pub fn scroll_down(&mut self, rows: usize) { + self.scroll = self.scroll.saturating_add(rows).min(self.scroll_limit()); + } + + pub fn scroll_up(&mut self, rows: usize) { + self.scroll = self.scroll.saturating_sub(rows); + } + + fn scroll_limit(&self) -> usize { + self.document + .fields() + .iter() + .map(|field| field.value().len().saturating_add(1)) + .sum() + } +} + +impl fmt::Debug for EntryViewer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EntryViewer") + .field("document", &self.document) + .field("focused", &self.focused) + .field("revealed", &self.revealed.map(|_| "[REDACTED]")) + .field("scroll", &self.scroll) + .finish() + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::path::PathBuf; + + use ironstorage::{ + crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + document::{EntryDocument, EntryDocumentService}, + repository::{Repository, SecretBytes}, + }; + + struct FixtureSecrets; + + impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + let passphrase = match key.fingerprint().as_str() { + "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" => b"fixture-alice-passphrase".to_vec(), + "B37027B56FC406BD3F6A622B2AC03492B992D06F" => b"fixture-bob-passphrase".to_vec(), + _ => return Err(SecretProviderError::Unavailable), + }; + Ok(SecretBytes::new(passphrase)) + } + } + + pub(crate) fn fixture_document(entry: &str) -> EntryDocument { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let repository = Repository::open(root.join("stores/basic")).expect("fixture repository"); + let keys = KeyStore::load(root.join("keys")).expect("fixture keys"); + EntryDocumentService::new(&repository, &keys) + .open(entry, &mut FixtureSecrets) + .expect("fixture document") + } +} + +#[cfg(test)] +mod tests { + use super::{test_support::fixture_document, *}; + + #[test] + fn focus_wraps_and_always_hides_a_revealed_secret() { + let mut viewer = EntryViewer::new(fixture_document("email/personal")); + assert!(viewer.reveal_focused()); + let password = viewer.focused_field().expect("password").id(); + assert!(viewer.is_revealed(password)); + + viewer.focus_next(); + assert!(!viewer.is_revealed(password)); + assert_eq!(viewer.focused_index(), Some(1)); + viewer.focus_previous(); + assert_eq!(viewer.focused_index(), Some(0)); + } + + #[test] + fn copy_uses_only_the_focused_structured_value() { + let mut viewer = EntryViewer::new(fixture_document("email/personal")); + viewer.focus_next(); + let copied = viewer.copy_focused().expect("copy value"); + assert_eq!(copied.expose(), b"alice@example.test"); + assert!(!format!("{viewer:?}").contains("correct horse fixture")); + } + + #[test] + fn ordinary_fields_do_not_gain_reveal_state_and_scroll_saturates() { + let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡")); + viewer.focus_next(); + assert!(!viewer.reveal_focused()); + viewer.scroll_down(usize::MAX); + let end = viewer.scroll(); + viewer.scroll_down(1); + assert_eq!(viewer.scroll(), end); + viewer.scroll_up(usize::MAX); + assert_eq!(viewer.scroll(), 0); + } +} diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 0916188..2585f45 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -7,7 +7,7 @@ use sha2::{Digest as _, Sha256}; use crate::{ command::EditRequest, crypto::{KeyStore, SecretProvider}, - otp::OtpUri, + otp::{OtpAlgorithm, OtpKind, OtpUri}, recipient::SigningPolicy, repository::{EntryPath, Repository, SecretBytes}, write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome}, @@ -46,6 +46,7 @@ pub struct EntryFieldMetadata { kind: EntryFieldKind, sensitivity: EntrySensitivity, name: Option, + otp: Option, value: Range, } @@ -61,6 +62,52 @@ impl EntryFieldMetadata { pub fn name(&self) -> Option<&str> { self.name.as_deref() } + + pub fn otp(&self) -> Option<&EntryOtpMetadata> { + self.otp.as_ref() + } +} + +/// Non-secret presentation metadata parsed from a validated `otpauth` URI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryOtpMetadata { + kind: OtpKind, + issuer: Option, + account: String, + algorithm: OtpAlgorithm, + digits: u32, + period: Option, + counter: Option, +} + +impl EntryOtpMetadata { + pub fn kind(&self) -> OtpKind { + self.kind + } + + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn algorithm(&self) -> OtpAlgorithm { + self.algorithm + } + + pub fn digits(&self) -> u32 { + self.digits + } + + pub fn period(&self) -> Option { + self.period + } + + pub fn counter(&self) -> Option { + self.counter + } } pub struct EntryField { @@ -205,6 +252,16 @@ impl EntryDocument { self.fields.first() } + /// Copy one structured field value into an independently zeroizing buffer. + /// + /// Frontends use this for explicit presentation actions without reparsing + /// the pass entry or copying label syntax alongside the selected value. + pub fn copy_field_value(&self, id: EntryFieldId) -> Result { + self.field(id) + .map(|field| SecretBytes::new(field.value().to_vec())) + .ok_or(DocumentError::UnknownField { id }) + } + pub fn conflict_token(&self) -> DocumentConflictToken { self.conflict_token } @@ -440,17 +497,30 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { kind: EntryFieldKind::Password, sensitivity: EntrySensitivity::Sensitive, name: Some("password".to_owned()), + otp: None, value: 0..line.len(), }; } if line.is_empty() { return blank_metadata(); } - if line.starts_with(b"otpauth://") && OtpUri::parse(SecretBytes::new(line.to_vec())).is_ok() { + if line.starts_with(b"otpauth://") + && let Ok(uri) = OtpUri::parse(SecretBytes::new(line.to_vec())) + { + let otp = EntryOtpMetadata { + kind: uri.kind(), + issuer: uri.issuer().map(str::to_owned), + account: uri.account().to_owned(), + algorithm: uri.algorithm(), + digits: uri.digits(), + period: uri.period(), + counter: uri.counter(), + }; return EntryFieldMetadata { kind: EntryFieldKind::OtpUri, sensitivity: EntrySensitivity::Sensitive, name: Some("otp".to_owned()), + otp: Some(otp), value: 0..line.len(), }; } @@ -465,6 +535,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { kind, sensitivity: field_sensitivity(name, kind), name: Some(name.to_owned()), + otp: None, value: value_start..line.len(), }; } @@ -473,6 +544,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { kind: EntryFieldKind::Note, sensitivity: EntrySensitivity::Sensitive, name: None, + otp: None, value: 0..line.len(), } } @@ -504,6 +576,7 @@ fn blank_metadata() -> EntryFieldMetadata { kind: EntryFieldKind::Blank, sensitivity: EntrySensitivity::Empty, name: None, + otp: None, value: 0..0, } } diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index f863a58..4d363ac 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -117,6 +117,19 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { document.fields()[4].metadata().sensitivity(), EntrySensitivity::Sensitive ); + let otp = document.fields()[4] + .metadata() + .otp() + .expect("validated OTP metadata"); + assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp); + assert_eq!(otp.issuer(), Some("Example")); + assert_eq!(otp.account(), "alice"); + assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1); + assert_eq!(otp.digits(), 6); + assert_eq!(otp.period(), Some(30)); + assert_eq!(otp.counter(), None); + let copied = document.copy_field_value(document.fields()[2].id())?; + assert_eq!(copied.expose(), b"one"); assert!(!format!("{document:?}").contains("pässwörd")); assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP")); Ok(())