#![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] mod editor; mod navigation; use std::{ sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant}, }; use editor::EntryEditor; use iced::{ Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, widget::{button, column, container, pane_grid, row, scrollable, text, text_input}, window, }; use ironstorage::{ authentication::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, config::Config, crypto::{KeyInfo, KeyStore}, document::{DocumentError, EntryDocument, EntryFieldId, EntryFieldKind, EntrySensitivity}, generate::GeneratorConfig, git::{AutomaticEntryCommitter, GitIdentity}, presentation::{ClipboardWait, NativeClipboardManager}, read::{TreeModel, TreeNodeId}, repository::{Repository, SecretBytes}, secret_store::{SecretProtectionPolicy, SecretStoreBackend}, write::{WriteError, WriteOutcome}, }; use navigation::{NavigationIntent, NavigationKey, NavigationTree}; use zeroize::Zeroizing; type OpenCompletion = Arc>>>; type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; #[derive(Clone)] enum Message { StartupLoaded(Box>), TreeLoaded { generation: u64, completion: TreeCompletion, }, RefreshTree, SidebarActivate(TreeNodeId), SidebarNavigate(NavigationKey), TogglePaneFocus, PaneResized(pane_grid::ResizeEvent), EntryPathChanged(String), OpenEntry, OpenFinished { generation: u64, entry: String, completion: OpenCompletion, }, AuthenticationFinished { generation: u64, result: Result, }, FieldChanged(EntryFieldId, Zeroizing), AddAfter(Option), Remove(EntryFieldId), MoveUp(EntryFieldId), MoveDown(EntryFieldId), ToggleReveal(EntryFieldId), RequestGenerate(EntryFieldId), ConfirmGenerate, CancelGenerate, Copy(EntryFieldId), CopyFinished { generation: u64, result: Result, }, Save, SaveFinished { generation: u64, completion: SaveCompletion, }, RequestReload, RequestClose(window::Id), ConfirmSave, ConfirmDiscard, CancelDiscard, ReloadConflict, KeepConflictDraft, UserActivity, Tick, Lock, } #[derive(Debug)] enum AuthenticationView { Loading, Locked, Authenticating, Unlocked(Duration), Unavailable(String), } #[derive(Default)] struct SensitiveUiState { clipboard_cancel: Option>, clipboard_generation: u64, } impl SensitiveUiState { fn clear(&mut self) { self.clipboard_generation = self.clipboard_generation.wrapping_add(1); if let Some(cancel) = self.clipboard_cancel.take() { cancel.store(true, Ordering::Release); } } fn begin_copy(&mut self) -> (u64, Arc) { self.clear(); let cancel = Arc::new(AtomicBool::new(false)); self.clipboard_cancel = Some(Arc::clone(&cancel)); (self.clipboard_generation, cancel) } fn finish_copy(&mut self, generation: u64) -> bool { if generation != self.clipboard_generation { return false; } self.clipboard_cancel = None; true } } struct App { authentication: AuthenticationView, config: Option, session: Option, key: Option, handle: Option, sensitive: SensitiveUiState, authentication_generation: u64, operation_generation: u64, tree_generation: u64, panes: pane_grid::State, pane_focus: PaneFocus, navigation: NavigationTree, tree_state: TreeState, after_authentication: Option, entry_path: String, editor: Option, saving: bool, confirmation: Option, after_save: Option, generate_confirmation: Option, conflict: bool, status: String, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PaneKind { Sidebar, Content, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PaneFocus { Sidebar, Content, } #[derive(Clone, Debug, Eq, PartialEq)] enum TreeState { Loading, Ready, Error(String), } #[derive(Clone, Debug, Eq, PartialEq)] enum PendingAction { OpenEntry(String), Reload(String), CloseWindow(window::Id), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DirtyDecision { Confirm, Execute, } #[derive(Debug, Eq, PartialEq)] enum LeasePoll { Idle, Active(Duration), Expired, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SaveFailureKind { Conflict, Unchanged, Storage, } #[derive(Debug)] struct SaveFailure { kind: SaveFailureKind, message: String, } fn main() -> iced::Result { iced::application(App::new, App::update, App::view) .title(ironstorage::PRODUCT_NAME) .subscription(App::subscription) .exit_on_close_request(false) .window(window::Settings { size: Size::new(1_080.0, 720.0), min_size: Some(Size::new(720.0, 480.0)), ..window::Settings::default() }) .run() } impl App { fn new() -> (Self, Task) { ( Self { authentication: AuthenticationView::Loading, config: None, session: None, key: None, handle: None, sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, tree_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), }), pane_focus: PaneFocus::Sidebar, navigation: NavigationTree::default(), tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor: None, saving: false, confirmation: None, after_save: None, generate_confirmation: None, conflict: false, status: "Loading shared configuration…".to_owned(), }, Task::perform(load_authentication(), |result| { Message::StartupLoaded(Box::new(result)) }), ) } fn update(&mut self, message: Message) -> Task { match message { Message::StartupLoaded(result) => match *result { Ok((config, session, key)) => { self.config = Some(config); self.session = Some(session); self.key = Some(key); self.authentication = AuthenticationView::Locked; self.status = "Entry names remain available while protected content is locked." .to_owned(); return self.begin_tree_refresh(); } Err(error) => { self.tree_state = TreeState::Error(error.clone()); self.authentication = AuthenticationView::Unavailable(error.clone()); self.status = error; } }, Message::TreeLoaded { generation, completion, } => { let Some(result) = take_completion(&completion) else { return Task::none(); }; if generation != self.tree_generation { return Task::none(); } match result { Ok(model) => { self.navigation.replace(&model); self.tree_state = TreeState::Ready; self.status = if self.navigation.is_empty() { "The password store is empty.".to_owned() } else { "Password-store tree refreshed.".to_owned() }; } Err(error) => { self.tree_state = TreeState::Error(error.clone()); self.status = format!("Tree refresh failed: {error}"); } } } Message::RefreshTree => return self.begin_tree_refresh(), Message::SidebarActivate(id) => { self.touch_user_activity(); self.pane_focus = PaneFocus::Sidebar; let intent = self.navigation.activate(id); return self.handle_navigation(intent); } Message::SidebarNavigate(key) => { self.touch_user_activity(); if self.pane_focus == PaneFocus::Sidebar { let intent = self.navigation.navigate(key); return self.handle_navigation(intent); } } Message::TogglePaneFocus => { self.touch_user_activity(); self.pane_focus = match self.pane_focus { PaneFocus::Sidebar => PaneFocus::Content, PaneFocus::Content => PaneFocus::Sidebar, }; } Message::PaneResized(event) => { self.panes .resize(event.split, event.ratio.clamp(0.18, 0.55)); } Message::EntryPathChanged(path) => { self.pane_focus = PaneFocus::Content; self.entry_path = path; } Message::OpenEntry => { self.pane_focus = PaneFocus::Content; let entry = self.entry_path.trim().to_owned(); if entry.is_empty() { self.status = "Enter an entry path first.".to_owned(); } else { return self.request_action(PendingAction::OpenEntry(entry)); } } Message::OpenFinished { generation, entry, completion, } => { let Some(result) = take_completion(&completion) else { return Task::none(); }; if generation != self.operation_generation { return Task::none(); } match result { Ok(document) => { self.entry_path = entry.clone(); let _selected = self.navigation.select_entry_path(&entry); self.pane_focus = PaneFocus::Content; self.editor = Some(EntryEditor::new(document)); self.conflict = false; self.status = format!("Editing {entry}"); } Err(error) => self.status = format!("Open failed: {error}"), } } Message::AuthenticationFinished { generation, result } => { if generation != self.authentication_generation { if result.is_ok() && let Some(session) = &self.session { let _ignored = session.manual_lock(); } return Task::none(); } match result { Ok(handle) => match handle.remaining_time() { Ok(remaining) => { self.handle = Some(handle); self.authentication = AuthenticationView::Unlocked(remaining); self.status = "Protected content is unlocked.".to_owned(); if let Some(action) = self.after_authentication.take() { return self.execute_action(action); } } Err(error) => self.authentication_lost(error.to_string()), }, Err(error) => { self.authentication_lost(error.clone()); self.status = format!("Authentication failed: {error}"); } } } Message::FieldChanged(id, value) => { self.pane_focus = PaneFocus::Content; self.edit(|editor| editor.update_raw(id, value.as_bytes())); } Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)), Message::Remove(id) => self.edit(|editor| editor.remove(id)), Message::MoveUp(id) => self.edit(|editor| editor.move_up(id)), Message::MoveDown(id) => self.edit(|editor| editor.move_down(id)), Message::ToggleReveal(id) => self.edit(|editor| editor.toggle_reveal(id)), Message::RequestGenerate(id) => { let has_value = self .editor .as_ref() .and_then(|editor| editor.document().field(id)) .is_some_and(|field| !field.value().is_empty()); if has_value { self.generate_confirmation = Some(id); } else { self.generate(id); } } Message::ConfirmGenerate => { if let Some(id) = self.generate_confirmation.take() { self.generate(id); } } Message::CancelGenerate => self.generate_confirmation = None, Message::Copy(id) => { let (Some(config), Some(editor)) = (&self.config, &self.editor) else { return Task::none(); }; let value = match editor.copy_value(id) { Ok(value) => value, Err(error) => { self.status = error.to_string(); return Task::none(); } }; let (generation, cancel) = self.sensitive.begin_copy(); self.status = "Copied; automatic clipboard cleanup is active.".to_owned(); return Task::perform( copy_to_clipboard(value, config.clipboard_timeout(), cancel), move |result| Message::CopyFinished { generation, result }, ); } Message::CopyFinished { generation, result } => { if self.sensitive.finish_copy(generation) { self.status = result.unwrap_or_else(|error| format!("Clipboard: {error}")); } } Message::Save => return self.begin_save(), Message::SaveFinished { generation, completion, } => { let Some((editor, result)) = take_completion(&completion) else { return Task::none(); }; if generation != self.operation_generation { return Task::none(); } self.saving = false; match result { Ok(outcome) => { self.editor = None; self.conflict = false; if let Some(action) = self.after_save.take() { return self.execute_action(action); } return self.begin_open(outcome.path().to_string()); } Err(error) => { self.conflict = error.kind == SaveFailureKind::Conflict; self.status = format!("Save failed: {}. Draft retained.", error.message); self.editor = Some(editor); self.confirmation = self.after_save.take(); } } } Message::RequestReload => { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { return self.request_action(PendingAction::Reload(entry)); } } Message::RequestClose(id) => { return self.request_action(PendingAction::CloseWindow(id)); } Message::ConfirmSave => { self.after_save = self.confirmation.take(); return self.begin_save(); } Message::ConfirmDiscard => { let action = self.confirmation.take(); self.editor = None; self.conflict = false; if let Some(action) = action { return self.execute_action(action); } } Message::CancelDiscard => self.confirmation = None, Message::ReloadConflict => { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { self.editor = None; self.conflict = false; return self.execute_action(PendingAction::Reload(entry)); } } Message::KeepConflictDraft => self.conflict = false, Message::UserActivity => self.touch_user_activity(), Message::Tick => { if let Some(session) = &self.session { match poll_lease(session, &mut self.handle, &mut self.sensitive) { Ok(LeasePoll::Active(remaining)) => { self.authentication = AuthenticationView::Unlocked(remaining); } Ok(LeasePoll::Expired) => { self.authentication_lost("the authentication lease expired".to_owned()); } Ok(LeasePoll::Idle) => {} Err(error) => self.authentication_lost(error.to_string()), } } } Message::Lock => { self.authentication_generation = self.authentication_generation.wrapping_add(1); let result = self .session .as_ref() .map_or(Ok(()), NativeAuthenticationSession::manual_lock); self.authentication_lost("manually locked".to_owned()); if let Err(error) = result { self.authentication = AuthenticationView::Unavailable(error.to_string()); self.status = format!("Lock failed: {error}"); } } } Task::none() } fn begin_tree_refresh(&mut self) -> Task { let Some(config) = self.config.clone() else { return Task::none(); }; self.tree_generation = self.tree_generation.wrapping_add(1); let generation = self.tree_generation; self.tree_state = TreeState::Loading; Task::perform( async move { Arc::new(Mutex::new(Some(load_tree(&config)))) }, move |completion| Message::TreeLoaded { generation, completion, }, ) } fn handle_navigation(&mut self, intent: NavigationIntent) -> Task { match intent { NavigationIntent::OpenEntry(entry) => { self.request_action(PendingAction::OpenEntry(entry)) } NavigationIntent::None => iced::widget::operation::snap_to( sidebar_scroll_id(), scrollable::RelativeOffset { x: 0.0, y: self.navigation.selected_ratio(), }, ), } } fn request_action(&mut self, action: PendingAction) -> Task { if self.saving { self.after_save = Some(action); self.status = "Waiting for the active save to finish…".to_owned(); return Task::none(); } if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { let _restored = self.navigation.select_entry_path(&entry); } self.confirmation = Some(action); Task::none() } else { self.execute_action(action) } } fn execute_action(&mut self, action: PendingAction) -> Task { match action { PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => { self.editor = None; self.begin_open(entry) } PendingAction::CloseWindow(id) => { self.sensitive.clear(); window::close(id) } } } fn begin_open(&mut self, entry: String) -> Task { let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else { self.after_authentication = Some(PendingAction::OpenEntry(entry)); return self.begin_authentication(); }; self.operation_generation = self.operation_generation.wrapping_add(1); let generation = self.operation_generation; self.status = format!("Opening {entry}…"); Task::perform( async move { let result = load_document(&config, &entry, handle); let completion = Arc::new(Mutex::new(Some(result))); (entry, completion) }, move |(entry, completion)| Message::OpenFinished { generation, entry, completion, }, ) } fn begin_authentication(&mut self) -> Task { let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else { return Task::none(); }; if matches!(self.authentication, AuthenticationView::Authenticating) { return Task::none(); } self.authentication_generation = self.authentication_generation.wrapping_add(1); let generation = self.authentication_generation; self.authentication = AuthenticationView::Authenticating; self.status = "Waiting for secure-storage authentication…".to_owned(); Task::perform( async move { session .authenticate(&key) .map_err(|error| error.to_string()) }, move |result| Message::AuthenticationFinished { generation, result }, ) } fn begin_save(&mut self) -> Task { self.touch_user_activity(); let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else { return Task::none(); }; let Some(editor) = self.editor.take() else { return Task::none(); }; if !editor.is_dirty() { self.editor = Some(editor); self.status = "The document has no changes to save.".to_owned(); return Task::none(); } self.operation_generation = self.operation_generation.wrapping_add(1); let generation = self.operation_generation; self.saving = true; self.status = format!("Saving {}…", editor.entry()); Task::perform( async move { let result = handle .ensure_active() .map_err(|error| SaveFailure::storage(error.to_string())) .and_then(|()| save_document(&config, &editor)); Arc::new(Mutex::new(Some((editor, result)))) }, move |completion| Message::SaveFinished { generation, completion, }, ) } fn edit(&mut self, operation: impl FnOnce(&mut EntryEditor) -> Result<(), DocumentError>) { let Some(editor) = self.editor.as_mut() else { return; }; if let Err(error) = operation(editor) { self.status = error.to_string(); } else { self.status = format!("Editing {} (unsaved changes)", editor.entry()); self.conflict = false; } } fn generate(&mut self, id: EntryFieldId) { match GeneratorConfig::pass_defaults().generate_secret(None, false) { Ok(password) => self.edit(|editor| editor.replace_value(id, password)), Err(error) => self.status = format!("Password generation failed: {error}"), } } fn touch_user_activity(&mut self) { if let Some(handle) = &self.handle { match handle .touch_user_activity() .and_then(|()| handle.remaining_time()) { Ok(remaining) => { self.authentication = AuthenticationView::Unlocked(remaining); } Err(error) => self.authentication_lost(error.to_string()), } } } fn authentication_lost(&mut self, reason: String) { self.operation_generation = self.operation_generation.wrapping_add(1); self.handle = None; self.sensitive.clear(); self.editor = None; self.saving = false; self.confirmation = None; self.after_save = None; self.after_authentication = None; self.generate_confirmation = None; self.conflict = false; self.authentication = AuthenticationView::Locked; self.status = reason; } fn subscription(&self) -> Subscription { Subscription::batch([ time::every(Duration::from_secs(1)).map(|_| Message::Tick), event::listen_with(|event, _status, _window| event_message(&event)), window::close_requests().map(Message::RequestClose), ]) } fn view(&self) -> Element<'_, Message> { if let Some(action) = &self.confirmation { return confirmation_view(action); } if let Some(id) = self.generate_confirmation { return generate_confirmation_view(id); } let authentication = match &self.authentication { AuthenticationView::Loading => "Loading".to_owned(), AuthenticationView::Locked => "Locked".to_owned(), AuthenticationView::Authenticating => "Authenticating".to_owned(), AuthenticationView::Unlocked(remaining) => { format!("Unlocked · {}s", remaining.as_secs()) } AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"), }; let panes = pane_grid(&self.panes, |_pane, kind, _maximized| { pane_grid::Content::new(match kind { PaneKind::Sidebar => sidebar_view( &self.navigation, &self.tree_state, self.pane_focus == PaneFocus::Sidebar, ), PaneKind::Content => content_view(self), }) }) .spacing(1) .min_size(220) .on_resize(8, Message::PaneResized); container( column![ row![ text(authentication), text(&self.status).size(14), text("Tab changes pane focus").size(12), ] .spacing(16) .padding(10), panes, ] .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .into() } } fn sidebar_scroll_id() -> iced::widget::Id { iced::widget::Id::new("desktop-navigation-tree") } fn sidebar_view<'a>( navigation: &'a NavigationTree, state: &'a TreeState, focused: bool, ) -> Element<'a, Message> { let mut rows = column![ row![ text(if focused { "Password Store · focused" } else { "Password Store" }) .size(20), button("Refresh").on_press(Message::RefreshTree), ] .spacing(8) ] .spacing(4) .padding(10); match state { TreeState::Loading => { rows = rows.push(text("Loading password-store tree…").size(13)); } TreeState::Error(error) => { rows = rows.push(text(format!("Tree unavailable: {error}")).size(13)); } TreeState::Ready if navigation.is_empty() => { rows = rows.push(text("This password store is empty.").size(13)); } TreeState::Ready => {} } let selected = navigation.selected(); for node in navigation.rows() { let marker = if node.id.is_directory() { if node.expanded { "▾" } else { "▸" } } else { "•" }; let mut flags = Vec::new(); if node.indicators.has_conflict() { flags.push("conflict"); } if node.indicators.is_changed() { flags.push("changed"); } if node.indicators.is_locked() { flags.push("locked"); } let suffix = if flags.is_empty() { String::new() } else { format!(" · {}", flags.join(", ")) }; let selected = selected == Some(&node.id); let item = button( row![ container(text("")).width(Length::Fixed((node.depth * 16) as f32)), text(marker), text(format!("{}{}", node.name, suffix)), ] .spacing(5), ) .width(Length::Fill) .on_press(Message::SidebarActivate(node.id)) .style(if selected { button::primary } else { button::text }); rows = rows.push(item); } scrollable(rows) .id(sidebar_scroll_id()) .height(Length::Fill) .into() } fn content_view(app: &App) -> Element<'_, Message> { let open = row![ text(if app.pane_focus == PaneFocus::Content { "Content · focused" } else { "Content" }) .size(20), text_input("Entry path", &app.entry_path) .on_input(Message::EntryPathChanged) .on_submit(Message::OpenEntry), button("Edit").on_press(Message::OpenEntry), button("Reload").on_press(Message::RequestReload), button("Lock").on_press(Message::Lock), ] .spacing(8); let body: Element<'_, Message> = if app.saving { container(text("Saving without discarding the draft…")) .center(Length::Fill) .into() } else if let Some(editor) = &app.editor { editor_view(editor, app.conflict) } else { container(text( "Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.", )) .center(Length::Fill) .into() }; container(column![open, body].spacing(12).padding(12)) .width(Length::Fill) .height(Length::Fill) .into() } impl SaveFailure { fn storage(message: String) -> Self { Self { kind: SaveFailureKind::Storage, message, } } } fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision { if editor.is_some_and(EntryEditor::is_dirty) { DirtyDecision::Confirm } else { DirtyDecision::Execute } } fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { let mut fields = column![ row![ text(format!( "{}{}", editor.entry(), if editor.is_dirty() { " •" } else { "" } )) .size(22), button("Save").on_press(Message::Save), button("Add line").on_press(Message::AddAfter(None)), ] .spacing(8) ] .spacing(10); if conflict { fields = fields.push( row![ text("The stored entry changed. Reload it or keep the complete local draft."), button("Reload stored entry").on_press(Message::ReloadConflict), button("Keep draft").on_press(Message::KeepConflictDraft), ] .spacing(8), ); } for field in editor.fields() { let id = field.id(); let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive; let value = std::str::from_utf8(field.contents().expose()).ok(); let label = field .metadata() .name() .map(str::to_owned) .unwrap_or_else(|| format!("{:?}", field.metadata().kind())); let input = text_input( if value.is_some() { "Entry line" } else { "Non-UTF-8 line preserved" }, value.unwrap_or(""), ) .secure(sensitive && !editor.is_revealed(id)) .on_input_maybe( value .is_some() .then_some(move |value| Message::FieldChanged(id, Zeroizing::new(value))), ) .on_submit(Message::AddAfter(Some(id))); let mut actions = row![ button("↑").on_press(Message::MoveUp(id)), button("↓").on_press(Message::MoveDown(id)), button("Add below").on_press(Message::AddAfter(Some(id))), button("Remove").on_press(Message::Remove(id)), button("Copy value").on_press(Message::Copy(id)), ] .spacing(6); if sensitive { actions = actions.push( button(if editor.is_revealed(id) { "Hide" } else { "Reveal" }) .on_press(Message::ToggleReveal(id)), ); } if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { actions = actions.push(button("Generate").on_press(Message::RequestGenerate(id))); } fields = fields.push(column![text(label).size(14), input, actions].spacing(4)); } scrollable(fields).height(Length::Fill).into() } fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { let description = match action { PendingAction::OpenEntry(entry) => format!("Open {entry}"), PendingAction::Reload(entry) => format!("Reload {entry}"), PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(), }; container( column![ text("Save changes?").size(26), text(format!( "{description} would discard the current structured editor draft." )), row![ button("Save").on_press(Message::ConfirmSave), button("Discard").on_press(Message::ConfirmDiscard), button("Cancel").on_press(Message::CancelDiscard), ] .spacing(8), ] .spacing(12), ) .center(Length::Fill) .into() } fn generate_confirmation_view(id: EntryFieldId) -> Element<'static, Message> { container( column![ text("Replace the current value?").size(26), text("The generated password replaces only this storage-provided field value."), row![ button("Replace").on_press(Message::ConfirmGenerate), button("Cancel").on_press(Message::CancelGenerate), ] .spacing(8), text(format!("Field {}", id.value())).size(12), ] .spacing(12), ) .center(Length::Fill) .into() } async fn load_authentication() -> Result<(Config, NativeAuthenticationSession, KeyInfo), String> { let config = Config::load(None).map_err(|error| error.to_string())?; let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; let handle = keys .resolve(config.default_key().as_str()) .map_err(|error| error.to_string())?; let key = keys .infos() .find(|key| key.fingerprint() == handle.fingerprint()) .ok_or_else(|| "the configured GPG key is unavailable".to_owned())?; let session = NativeAuthenticationSession::system( SecretProtectionPolicy::default(), config.authentication_timeout(), ) .map_err(|error| error.to_string())?; Ok((config, session, key)) } fn load_document( config: &Config, entry: &str, mut handle: NativeAuthenticationHandle, ) -> Result { let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; let keys = 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: &Config) -> Result { let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; ironstorage::read::VaultReader::new(&repository, &keys) .list(&ironstorage::repository::DirectoryPath::root()) .map_err(|error| error.to_string()) } fn save_document(config: &Config, editor: &EntryEditor) -> Result { let repository = Repository::open(config.vault()) .map_err(|error| SaveFailure::storage(error.to_string()))?; let keys = KeyStore::load(config.key_material()) .map_err(|error| SaveFailure::storage(error.to_string()))?; let entry = editor.entry(); let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage()) .map_err(|error| SaveFailure::storage(error.to_string()))?; ironstorage::document::EntryDocumentService::new(&repository, &keys) .save_recoverable(editor.document(), None, &mut committer) .map_err(|error| SaveFailure { kind: match &error { DocumentError::Write(WriteError::ConcurrentModification { .. }) => { SaveFailureKind::Conflict } DocumentError::Write(WriteError::Unchanged) => SaveFailureKind::Unchanged, _ => SaveFailureKind::Storage, }, message: error.to_string(), }) } async fn copy_to_clipboard( value: SecretBytes, timeout: ironstorage::presentation::ClipboardTimeout, cancel: Arc, ) -> Result { let mut clipboard = NativeClipboardManager::system(timeout).map_err(|error| error.to_string())?; let disposition = clipboard .copy_with(&value, |duration| { let deadline = Instant::now() + duration; while Instant::now() < deadline { if cancel.load(Ordering::Acquire) { return ClipboardWait::Cancelled; } thread::sleep( deadline .saturating_duration_since(Instant::now()) .min(Duration::from_millis(50)), ); } ClipboardWait::Elapsed }) .map_err(|error| error.to_string())?; Ok(format!("Clipboard cleanup complete: {disposition:?}")) } fn poll_lease( session: &AuthenticationSession, handle: &mut Option>, sensitive: &mut SensitiveUiState, ) -> Result { if session.expire()? { *handle = None; sensitive.clear(); return Ok(LeasePoll::Expired); } handle .as_ref() .map(AuthenticationHandle::remaining_time) .transpose() .map(|remaining| remaining.map_or(LeasePoll::Idle, LeasePoll::Active)) } fn event_message(event: &Event) -> Option { if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) { return Some(Message::Save); } let navigation = match key.as_ref() { keyboard::Key::Named(keyboard::key::Named::Tab) => { return Some(Message::TogglePaneFocus); } keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous, keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next, keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse, keyboard::Key::Named(keyboard::key::Named::ArrowRight) => NavigationKey::Expand, keyboard::Key::Named(keyboard::key::Named::Enter) => NavigationKey::Activate, keyboard::Key::Named(keyboard::key::Named::Home) => NavigationKey::First, keyboard::Key::Named(keyboard::key::Named::End) => NavigationKey::Last, _ => return is_deliberate_activity(event).then_some(Message::UserActivity), }; return Some(Message::SidebarNavigate(navigation)); } is_deliberate_activity(event).then_some(Message::UserActivity) } fn is_deliberate_activity(event: &Event) -> bool { matches!( event, Event::Keyboard(keyboard::Event::KeyPressed { .. }) | Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }) | Event::Touch( touch::Event::FingerPressed { .. } | touch::Event::FingerMoved { .. } | touch::Event::FingerLifted { .. } ) ) } fn take_completion(completion: &Arc>>) -> Option { completion.lock().ok()?.take() } #[cfg(test)] mod tests { use std::{ collections::BTreeMap, fs, path::Path, sync::{Arc, Mutex}, }; use iced::{Point, window}; use ironstorage::{ authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, crypto::{SecretProvider, SecretProviderError}, document::EntryDocumentService, repository::EntryPath, secret_store::{ SecretCachePolicy, SecretLocator, SecretProtection, SecretReference, SecretStore, SecretStoreError, }, }; use super::*; #[derive(Clone, Default)] struct ManualClock(Arc>); impl ManualClock { fn advance(&self, duration: Duration) { *self.0.lock().expect("test clock") += duration; } } impl AuthenticationClock for ManualClock { fn now(&self) -> Duration { *self.0.lock().expect("test clock") } } #[derive(Clone, Default)] struct MemoryBackend(Arc>>); impl SecretStoreBackend for MemoryBackend { fn create( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.0 .lock() .expect("test backend") .insert(locator.clone(), SecretBytes::new(value.to_vec())); Ok(()) } fn retrieve( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result { self.0 .lock() .expect("test backend") .get(locator) .map(|value| SecretBytes::new(value.expose().to_vec())) .ok_or(SecretStoreError::Missing) } fn replace( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.create(locator, SecretProtection::DeviceUnlocked, value) } fn delete( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result<(), SecretStoreError> { self.0.lock().expect("test backend").remove(locator); Ok(()) } fn lock(&self) -> Result<(), SecretStoreError> { Ok(()) } fn unlock(&self) -> Result<(), SecretStoreError> { Ok(()) } } struct FixtureSecrets; impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, _key: &KeyInfo) -> Result { Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) } } fn session( timeout: Duration, ) -> ( AuthenticationSession, KeyInfo, ManualClock, ) { let mut keys = KeyStore::new(); let [key] = keys .import(include_bytes!( "../../../crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc" )) .expect("fixture key") .try_into() .expect("one fixture key"); let backend = MemoryBackend::default(); let store = SecretStore::new( backend.clone(), SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ); store.unlock().expect("unlock fixture store"); store .create( &SecretReference::openpgp_passphrase(key.fingerprint().as_str()) .expect("fixture reference"), SecretBytes::new(b"fixture-alice-passphrase".to_vec()), ) .expect("provision fixture passphrase"); store.lock().expect("lock fixture store"); let clock = ManualClock::default(); let session = AuthenticationSession::with_clock( backend, SecretProtectionPolicy::device_unlocked(), AuthenticationTimeout::new(timeout).expect("valid timeout"), clock.clone(), ); (session, key, clock) } fn fixture_config() -> (tempfile::TempDir, Config) { let temporary = tempfile::tempdir().expect("temporary vault"); let vault = temporary.path().join("vault"); let native = temporary.path().join("native"); fs::create_dir_all(&vault).expect("vault"); fs::create_dir_all(&native).expect("native"); let keys = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility/keys"); fs::write( vault.join(".gpg-id"), b"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\n", ) .expect("recipient policy"); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", vault, keys ), ) .expect("configuration"); let config = Config::load(Some(&config_path)).expect("load configuration"); (temporary, config) } fn empty_editor(config: &Config, entry: &str) -> EntryEditor { let repository = Repository::open(config.vault()).expect("repository"); let keys = KeyStore::load(config.key_material()).expect("keys"); let document = EntryDocumentService::new(&repository, &keys) .open(entry, &mut FixtureSecrets) .expect("document"); EntryEditor::new(document) } fn test_app(editor: Option) -> App { App { authentication: AuthenticationView::Locked, config: None, session: None, key: None, handle: None, sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, tree_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), }), pane_focus: PaneFocus::Sidebar, navigation: NavigationTree::default(), tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor, saving: false, confirmation: None, after_save: None, generate_confirmation: None, conflict: false, status: String::new(), } } #[test] fn structured_fields_save_round_trip_and_stale_drafts_remain_recoverable() { let (_temporary, config) = fixture_config(); let mut editor = empty_editor(&config, "documents/editable"); editor.add_after(None).expect("password line"); let password = editor.fields()[0].id(); editor.update_raw(password, b"password").expect("password"); editor.add_after(Some(password)).expect("username line"); let username = editor.fields()[1].id(); editor .update_raw(username, b"username: alice") .expect("username"); editor.add_after(Some(username)).expect("first note line"); let first_note = editor.fields()[2].id(); editor .update_raw(first_note, b"first note line") .expect("first note"); editor .add_after(Some(first_note)) .expect("second note line"); let second_note = editor.fields()[3].id(); editor .update_raw(second_note, b"second note line") .expect("second note"); editor .add_after(Some(second_note)) .expect("unknown field line"); let unknown = editor.fields()[4].id(); editor .update_raw(unknown, b"custom-field: opaque") .expect("unknown field"); editor.move_up(unknown).expect("reorder unknown field"); editor.add_after(None).expect("temporary line"); let temporary = editor.fields().last().expect("temporary field").id(); editor.remove(temporary).expect("remove temporary line"); let expected = b"password\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line"; assert_eq!(editor.document().serialize().expose(), expected); save_document(&config, &editor).expect("initial save"); let reopened = empty_editor(&config, "documents/editable"); assert_eq!(reopened.document().serialize().expose(), expected); let mut winner = empty_editor(&config, "documents/editable"); let mut stale = empty_editor(&config, "documents/editable"); let winner_password = winner.fields()[0].id(); winner .update_raw(winner_password, b"winner") .expect("winner edit"); save_document(&config, &winner).expect("winner save"); let stale_password = stale.fields()[0].id(); stale .update_raw(stale_password, b"complete stale draft") .expect("stale edit"); let failure = save_document(&config, &stale).expect_err("stale save"); assert_eq!(failure.kind, SaveFailureKind::Conflict); assert!(stale.is_dirty()); assert_eq!( stale.document().serialize().expose(), b"complete stale draft\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line" ); let repository = Repository::open(config.vault()).expect("repository"); assert!( repository .read_entry(&EntryPath::parse("documents/editable").expect("path")) .is_ok() ); } #[test] fn every_destructive_path_uses_the_same_save_discard_cancel_guard() { let (_temporary, config) = fixture_config(); let mut editor = empty_editor(&config, "draft"); editor.add_after(None).expect("line"); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); for action in [ PendingAction::OpenEntry("other".to_owned()), PendingAction::Reload("draft".to_owned()), PendingAction::CloseWindow(window::Id::unique()), ] { assert!(matches!( action, PendingAction::OpenEntry(_) | PendingAction::Reload(_) | PendingAction::CloseWindow(_) )); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); } assert_eq!(dirty_decision(None), DirtyDecision::Execute); let draft = editor.document().serialize().expose().to_vec(); let mut app = test_app(Some(editor)); let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path")); let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path")); app.navigation.replace_test_nodes(vec![ navigation::TestNode { id: draft_id, name: "draft".to_owned(), children: Vec::new(), }, navigation::TestNode { id: other_id.clone(), name: "other".to_owned(), children: Vec::new(), }, ]); assert!(app.navigation.select_entry_path("draft")); let intent = app.navigation.activate(other_id); let _task = app.handle_navigation(intent); assert_eq!( app.navigation.selected().map(TreeNodeId::path), Some(Path::new("draft")) ); let _task = app.update(Message::CancelDiscard); assert!(app.confirmation.is_none()); assert_eq!( app.editor .as_ref() .expect("cancel retains editor") .document() .serialize() .expose(), draft ); let _task = app.request_action(PendingAction::OpenEntry("other".to_owned())); let _task = app.update(Message::ConfirmDiscard); assert!(app.editor.is_none()); assert_eq!( app.after_authentication, Some(PendingAction::OpenEntry("other".to_owned())) ); } #[test] fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() { let (_temporary, config) = fixture_config(); let mut editor = empty_editor(&config, "dirty"); editor.add_after(None).expect("dirty line"); let mut app = test_app(Some(editor)); app.sensitive.clipboard_cancel = Some(Arc::new(AtomicBool::new(false))); app.authentication_lost("locked".to_owned()); assert!(app.editor.is_none()); assert!(app.sensitive.clipboard_cancel.is_none()); let (generation, cancel) = app.sensitive.begin_copy(); app.sensitive.clear(); assert!(cancel.load(Ordering::Acquire)); assert!(!app.sensitive.finish_copy(generation)); let timeout = DEFAULT_AUTHENTICATION_TIMEOUT; let (session, key, clock) = session(timeout); let mut handle = Some(session.authenticate(&key).expect("authenticate")); let mut sensitive = SensitiveUiState { clipboard_cancel: Some(Arc::new(AtomicBool::new(false))), clipboard_generation: 0, }; clock.advance(timeout); assert_eq!( poll_lease(&session, &mut handle, &mut sensitive).expect("poll"), LeasePoll::Expired ); assert!(handle.is_none()); assert!(sensitive.clipboard_cancel.is_none()); } #[test] fn deliberate_input_and_primary_save_are_distinct_from_passive_events() { assert!(!is_deliberate_activity(&Event::Window( window::Event::Focused ))); assert!(!is_deliberate_activity(&Event::Mouse( mouse::Event::CursorMoved { position: Point::ORIGIN, } ))); let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)); assert!(matches!(event_message(&click), Some(Message::UserActivity))); let save = Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character("s".into()), modified_key: keyboard::Key::Character("s".into()), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyS), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::COMMAND, text: Some("s".into()), repeat: false, }); assert!(matches!(event_message(&save), Some(Message::Save))); let tab = Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Tab), modified_key: keyboard::Key::Named(keyboard::key::Named::Tab), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::NONE, text: None, repeat: false, }); assert!(matches!( event_message(&tab), Some(Message::TogglePaneFocus) )); } #[test] fn pane_focus_and_storage_errors_are_handled_without_repository_access() { let mut app = test_app(None); assert_eq!(app.pane_focus, PaneFocus::Sidebar); let _task = app.update(Message::TogglePaneFocus); assert_eq!(app.pane_focus, PaneFocus::Content); app.tree_generation = 7; let completion = Arc::new(Mutex::new(Some(Err("injected tree failure".to_owned())))); let _task = app.update(Message::TreeLoaded { generation: 7, completion, }); assert_eq!( app.tree_state, TreeState::Error("injected tree failure".to_owned()) ); assert!(app.navigation.is_empty()); } }