diff --git a/Cargo.lock b/Cargo.lock index 671c786..a162843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4037,6 +4037,8 @@ version = "0.1.0" dependencies = [ "iced", "ironstorage", + "tempfile", + "zeroize", ] [[package]] diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 80e4676..2f78bb8 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -13,3 +13,7 @@ path = "src/main.rs" [dependencies] iced.workspace = true ironstorage.workspace = true +zeroize.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/apps/desktop/src/editor.rs b/apps/desktop/src/editor.rs new file mode 100644 index 0000000..30a06e5 --- /dev/null +++ b/apps/desktop/src/editor.rs @@ -0,0 +1,154 @@ +//! Structured desktop editing state over storage-owned entry documents. + +use std::{collections::BTreeSet, fmt}; + +use ironstorage::{ + document::{ + DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntrySensitivity, + }, + repository::SecretBytes, +}; + +pub struct EntryEditor { + document: EntryDocument, + revealed: BTreeSet, + dirty: bool, +} + +impl EntryEditor { + pub fn new(document: EntryDocument) -> Self { + Self { + document, + revealed: BTreeSet::new(), + dirty: false, + } + } + + pub fn entry(&self) -> String { + self.document.path().to_string() + } + + pub fn document(&self) -> &EntryDocument { + &self.document + } + + pub fn fields(&self) -> &[EntryField] { + self.document.fields() + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn is_revealed(&self, id: EntryFieldId) -> bool { + self.revealed.contains(&id) + } + + pub fn toggle_reveal(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { + let field = self + .document + .field(id) + .ok_or(DocumentError::UnknownField { id })?; + if field.metadata().sensitivity() != EntrySensitivity::Sensitive { + return Ok(()); + } + if !self.revealed.remove(&id) { + self.revealed.insert(id); + } + Ok(()) + } + + pub fn update_raw(&mut self, id: EntryFieldId, value: &[u8]) -> Result<(), DocumentError> { + let unchanged = self + .document + .field(id) + .is_some_and(|field| field.contents().expose() == value); + if unchanged { + return Ok(()); + } + self.document + .update(id, EntryFieldDraft::line(value.to_vec())?)?; + self.dirty = true; + Ok(()) + } + + pub fn add_after(&mut self, id: Option) -> Result<(), DocumentError> { + let index = id + .and_then(|id| { + self.document + .fields() + .iter() + .position(|field| field.id() == id) + }) + .map_or(self.document.fields().len(), |index| index + 1); + self.document.add(index, EntryFieldDraft::blank())?; + self.dirty = true; + Ok(()) + } + + pub fn remove(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { + self.document.remove(id)?; + self.revealed.remove(&id); + self.dirty = true; + Ok(()) + } + + pub fn move_up(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { + let Some(index) = self + .document + .fields() + .iter() + .position(|field| field.id() == id) + else { + return Err(DocumentError::UnknownField { id }); + }; + if index > 0 { + self.document.reorder(id, index - 1)?; + self.dirty = true; + } + Ok(()) + } + + pub fn move_down(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { + let Some(index) = self + .document + .fields() + .iter() + .position(|field| field.id() == id) + else { + return Err(DocumentError::UnknownField { id }); + }; + if index + 1 < self.document.fields().len() { + self.document.reorder(id, index + 1)?; + self.dirty = true; + } + Ok(()) + } + + pub fn replace_value( + &mut self, + id: EntryFieldId, + value: SecretBytes, + ) -> Result<(), DocumentError> { + self.document + .replace_field_value(id, value.expose().to_vec())?; + self.revealed.remove(&id); + self.dirty = true; + Ok(()) + } + + pub fn copy_value(&self, id: EntryFieldId) -> Result { + self.document.copy_field_value(id) + } +} + +impl fmt::Debug for EntryEditor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EntryEditor") + .field("document", &self.document) + .field("revealed", &self.revealed) + .field("dirty", &self.dirty) + .finish() + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 82929cf..d2cfc61 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -1,30 +1,83 @@ #![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] -use std::time::Duration; +mod editor; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use editor::EntryEditor; use iced::{ Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch, - widget::{button, column, container, text}, + widget::{button, column, container, row, scrollable, text, text_input}, + window, }; use ironstorage::{ authentication::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, + config::Config, crypto::{KeyInfo, KeyStore}, - repository::SecretBytes, + document::{DocumentError, EntryDocument, EntryFieldId, EntryFieldKind, EntrySensitivity}, + generate::GeneratorConfig, + git::{AutomaticEntryCommitter, GitIdentity}, + presentation::{ClipboardWait, NativeClipboardManager}, + repository::{Repository, SecretBytes}, secret_store::{SecretProtectionPolicy, SecretStoreBackend}, + write::{WriteError, WriteOutcome}, }; +use zeroize::Zeroizing; -#[derive(Debug, Clone)] +type OpenCompletion = Arc>>>; +type SaveCompletion = Arc)>>>; + +#[derive(Clone)] enum Message { - StartupLoaded(Result<(NativeAuthenticationSession, KeyInfo), String>), - UnlockProtectedContent, + StartupLoaded(Box>), + 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, @@ -41,34 +94,67 @@ enum AuthenticationView { #[derive(Default)] struct SensitiveUiState { - decrypted: Option, - editor: Option, - clipboard_presenting: bool, + clipboard_cancel: Option>, + clipboard_generation: u64, } impl SensitiveUiState { fn clear(&mut self) { - self.decrypted = None; - self.editor = None; - self.clipboard_presenting = false; + self.clipboard_generation = self.clipboard_generation.wrapping_add(1); + if let Some(cancel) = self.clipboard_cancel.take() { + cancel.store(true, Ordering::Release); + } } - #[cfg(test)] - fn is_clear(&self) -> bool { - self.decrypted.is_none() && self.editor.is_none() && !self.clipboard_presenting + 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, - generation: u64, + authentication_generation: u64, + operation_generation: u64, + after_authentication: Option, + entry_path: String, + editor: Option, + saving: bool, + confirmation: Option, + after_save: Option, + generate_confirmation: Option, + conflict: bool, status: 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, @@ -76,10 +162,24 @@ enum LeasePoll { 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) .run() } @@ -88,54 +188,77 @@ impl App { ( Self { authentication: AuthenticationView::Loading, + config: None, session: None, key: None, handle: None, sensitive: SensitiveUiState::default(), - generation: 0, + authentication_generation: 0, + operation_generation: 0, + 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(), Message::StartupLoaded), + Task::perform(load_authentication(), |result| { + Message::StartupLoaded(Box::new(result)) + }), ) } fn update(&mut self, message: Message) -> Task { match message { - Message::StartupLoaded(Ok((session, key))) => { - self.session = Some(session); - self.key = Some(key); - self.authentication = AuthenticationView::Locked; - self.status = "No password store content is unlocked.".to_owned(); + 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(); + } + Err(error) => { + self.authentication = AuthenticationView::Unavailable(error.clone()); + self.status = error; + } + }, + Message::EntryPathChanged(path) => self.entry_path = path, + Message::OpenEntry => { + 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::StartupLoaded(Err(error)) => { - self.authentication = AuthenticationView::Unavailable(error.clone()); - self.status = error; - } - Message::UnlockProtectedContent => { - let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else { + Message::OpenFinished { + generation, + entry, + completion, + } => { + let Some(result) = take_completion(&completion) else { return Task::none(); }; - if matches!( - self.authentication, - AuthenticationView::Authenticating | AuthenticationView::Unlocked(_) - ) { + if generation != self.operation_generation { return Task::none(); } - self.generation = self.generation.wrapping_add(1); - let generation = self.generation; - self.authentication = AuthenticationView::Authenticating; - self.status = "Waiting for secure-storage authentication…".to_owned(); - return Task::perform( - async move { - session - .authenticate(&key) - .map_err(|error| error.to_string()) - }, - move |result| Message::AuthenticationFinished { generation, result }, - ); + match result { + Ok(document) => { + self.entry_path = entry.clone(); + 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.generation { + if generation != self.authentication_generation { if result.is_ok() && let Some(session) = &self.session { @@ -149,6 +272,9 @@ impl App { 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()), }, @@ -158,18 +284,114 @@ impl App { } } } - Message::UserActivity => { - if let Some(handle) = &self.handle { - match handle.touch_user_activity() { - Ok(()) => { - if let Ok(remaining) = handle.remaining_time() { - self.authentication = AuthenticationView::Unlocked(remaining); - } + Message::FieldChanged(id, value) => { + 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); } - Err(error) => self.authentication_lost(error.to_string()), + 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) { @@ -185,7 +407,7 @@ impl App { } } Message::Lock => { - self.generation = self.generation.wrapping_add(1); + self.authentication_generation = self.authentication_generation.wrapping_add(1); let result = self .session .as_ref() @@ -200,9 +422,152 @@ impl App { Task::none() } + 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 { + 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; } @@ -210,66 +575,207 @@ impl App { fn subscription(&self) -> Subscription { Subscription::batch([ time::every(Duration::from_secs(1)).map(|_| Message::Tick), - event::listen_with(|event, _status, _window| { - is_deliberate_activity(&event).then_some(Message::UserActivity) - }), + event::listen_with(|event, _status, _window| event_message(&event)), + window::close_requests().map(Message::RequestClose), ]) } fn view(&self) -> Element<'_, Message> { - let (heading, detail, action) = match &self.authentication { - AuthenticationView::Loading => ( - "Loading", - "Reading the shared IronStorage configuration.", - None, - ), - AuthenticationView::Locked => ( - "Locked", - "Encrypted entry names remain browsable. Protected content requires authentication.", - Some(button("Unlock protected content").on_press(Message::UnlockProtectedContent)), - ), - AuthenticationView::Authenticating => ( - "Authenticating", - "Complete or cancel the native secure-storage prompt.", - Some(button("Cancel and lock").on_press(Message::Lock)), - ), - AuthenticationView::Unlocked(remaining) => ( - "Unlocked", - if remaining.as_secs() == 1 { - "Authentication expires after 1 second of inactivity." - } else { - "Protected content is available until the inactivity lease expires." - }, - Some(button("Lock now").on_press(Message::Lock)), - ), - AuthenticationView::Unavailable(error) => ("Unavailable", error.as_str(), None), - }; - let remaining = match self.authentication { - AuthenticationView::Unlocked(remaining) => { - format!("{} seconds remaining", remaining.as_secs()) - } - _ => String::new(), - }; - let mut content = column![ - text(heading).size(28), - text(detail), - text(remaining), - text(&self.status).size(14), - ] - .spacing(12); - if let Some(action) = action { - content = content.push(action); + if let Some(action) = &self.confirmation { + return confirmation_view(action); } - container(content) - .width(Length::Fill) - .height(Length::Fill) + 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 lock = button("Lock").on_press(Message::Lock); + let open = row![ + text_input("Entry path", &self.entry_path) + .on_input(Message::EntryPathChanged) + .on_submit(Message::OpenEntry), + button("Edit").on_press(Message::OpenEntry), + button("Reload").on_press(Message::RequestReload), + lock, + ] + .spacing(8); + + let body: Element<'_, Message> = if self.saving { + container(text("Saving without discarding the draft…")) + .center(Length::Fill) + .into() + } else if let Some(editor) = &self.editor { + editor_view(editor, self.conflict) + } else { + container(text( + "Enter an encrypted entry path. Browsing its name does not authenticate; Edit does.", + )) .center(Length::Fill) .into() + }; + + container( + column![ + row![text(authentication), text(&self.status).size(14)].spacing(16), + open, + body, + ] + .spacing(12) + .padding(16), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() } } -async fn load_authentication() -> Result<(NativeAuthenticationSession, KeyInfo), String> { - let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; +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()) @@ -283,7 +789,68 @@ async fn load_authentication() -> Result<(NativeAuthenticationSession, KeyInfo), config.authentication_timeout(), ) .map_err(|error| error.to_string())?; - Ok((session, key)) + 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 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( @@ -303,6 +870,16 @@ fn poll_lease( .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 + && modifiers.command() + && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) + { + return Some(Message::Save); + } + is_deliberate_activity(event).then_some(Message::UserActivity) +} + fn is_deliberate_activity(event: &Event) -> bool { matches!( event, @@ -316,16 +893,25 @@ fn is_deliberate_activity(event: &Event) -> bool { ) } +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, @@ -406,6 +992,14 @@ mod tests { } } + 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, ) -> ( @@ -446,39 +1040,212 @@ mod tests { (session, key, clock) } - fn sensitive_state() -> SensitiveUiState { - SensitiveUiState { - decrypted: Some(SecretBytes::new(b"decrypted".to_vec())), - editor: Some(SecretBytes::new(b"dirty draft".to_vec())), - clipboard_presenting: true, + 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, + 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 default_and_configured_timeouts_clear_all_sensitive_ui_state() { - for timeout in [DEFAULT_AUTHENTICATION_TIMEOUT, Duration::from_secs(7)] { - let (session, key, clock) = session(timeout); - let mut handle = Some(session.authenticate(&key).expect("authenticate")); - let mut sensitive = sensitive_state(); + 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"); - clock.advance(timeout); + let reopened = empty_editor(&config, "documents/editable"); + assert_eq!(reopened.document().serialize().expose(), expected); - assert_eq!( - poll_lease(&session, &mut handle, &mut sensitive).expect("poll lease"), - LeasePoll::Expired - ); - assert!(handle.is_none()); - assert!(sensitive.is_clear()); - } + 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 only_deliberate_input_renews_the_storage_owned_lease() { - let timeout = Duration::from_secs(10); + 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 _task = app.request_action(PendingAction::OpenEntry("other".to_owned())); + 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 = sensitive_state(); + 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 ))); @@ -487,25 +1254,17 @@ mod tests { position: Point::ORIGIN, } ))); - clock.advance(timeout); - assert_eq!( - poll_lease(&session, &mut handle, &mut sensitive).expect("passive poll"), - LeasePoll::Expired - ); - - handle = Some(session.authenticate(&key).expect("reauthenticate")); - clock.advance(Duration::from_secs(9)); let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)); - assert!(is_deliberate_activity(&click)); - handle - .as_ref() - .expect("active handle") - .touch_user_activity() - .expect("renew from deliberate input"); - clock.advance(Duration::from_secs(9)); - assert_eq!( - poll_lease(&session, &mut handle, &mut sensitive).expect("active poll"), - LeasePoll::Active(Duration::from_secs(1)) - ); + 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))); } }