From bb650c2b5c360645db8bd382e40725f587e4d1b4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 18:58:33 +0200 Subject: [PATCH] Add desktop store and entry creation workflows --- apps/desktop/src/action.rs | 73 +- apps/desktop/src/editor.rs | 15 + apps/desktop/src/main.rs | 1137 ++++++++++++++++++++++++-- apps/desktop/src/native_menu.rs | 3 + apps/desktop/src/palette.rs | 10 + crates/storage/src/desktop.rs | 140 +++- crates/storage/src/git.rs | 7 +- crates/storage/tests/git_embedded.rs | 2 + 8 files changed, 1330 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index c6aa32b..dd339f0 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -6,6 +6,8 @@ use iced::keyboard::{self, key::Named}; pub enum UiAction { About, Settings, + InitializeStore, + NewFolder, NewEntry, OpenFolder, OpenEntry, @@ -24,6 +26,7 @@ pub enum UiAction { Refresh, ReloadEntry, EditEntry, + GeneratePassword, ToggleReveal, Lock, Minimize, @@ -35,6 +38,8 @@ impl UiAction { match self { Self::About => "about", Self::Settings => "settings", + Self::InitializeStore => "initialize-store", + Self::NewFolder => "new-folder", Self::NewEntry => "new-entry", Self::OpenFolder => "open-folder", Self::OpenEntry => "open-entry", @@ -53,6 +58,7 @@ impl UiAction { Self::Refresh => "refresh", Self::ReloadEntry => "reload-entry", Self::EditEntry => "edit-entry", + Self::GeneratePassword => "generate-password", Self::ToggleReveal => "toggle-reveal", Self::Lock => "lock", Self::Minimize => "minimize", @@ -112,6 +118,7 @@ pub struct ActionContext { pub modal_open: bool, pub focused_field: bool, pub focused_sensitive: bool, + pub focused_generatable: bool, pub entry_path: bool, } @@ -126,6 +133,13 @@ pub struct ActionSpec { pub const ACTIONS: &[ActionSpec] = &[ spec(UiAction::About, MenuGroup::App, "About IronStorage", None), spec(UiAction::Settings, MenuGroup::App, "Settings…", Some("⌘,")), + spec( + UiAction::InitializeStore, + MenuGroup::File, + "Initialize Store…", + None, + ), + spec(UiAction::NewFolder, MenuGroup::File, "New Folder…", None), spec( UiAction::Quit, MenuGroup::App, @@ -179,6 +193,12 @@ pub const ACTIONS: &[ActionSpec] = &[ None, ), spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None), + spec( + UiAction::GeneratePassword, + MenuGroup::Entry, + "Generate Password…", + None, + ), spec( UiAction::ToggleReveal, MenuGroup::Entry, @@ -244,8 +264,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.modal_open } UiAction::CommandPalette => !context.modal_open, - // Entry creation is not valid until the dedicated workflow exists. - UiAction::NewEntry => false, + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => { + context.storage_ready + && !context.dirty + && !context.saving + && !context.switching_vault + && !context.modal_open + } UiAction::OpenFolder => { context.storage_ready && !context.saving && !context.switching_vault } @@ -288,6 +313,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.editing && !context.switching_vault } + UiAction::GeneratePassword => { + context.unlocked + && context.editing + && context.focused_generatable + && !context.switching_vault + && !context.modal_open + } UiAction::ToggleReveal => { context.unlocked && context.document_open @@ -303,7 +335,25 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta return None; } Some(match action { - UiAction::NewEntry => "Entry creation is not available yet", + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry + if !context.storage_ready => + { + "Shared configuration is unavailable" + } + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.dirty => { + "Save or discard the current draft first" + } + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.saving => { + "Wait for the active save" + } + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry + if context.switching_vault => + { + "Wait for vault validation" + } + UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => { + "Close the current screen first" + } UiAction::OpenFolder if !context.storage_ready => "Shared configuration is unavailable", UiAction::OpenFolder if context.saving => "Wait for the active save", UiAction::OpenFolder => "Wait for vault validation", @@ -345,6 +395,13 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta UiAction::EditEntry if !context.document_open => "Open an entry first", UiAction::EditEntry if context.editing => "The entry editor is already open", UiAction::EditEntry => "Wait for vault validation", + UiAction::GeneratePassword if !context.unlocked => "Unlock an entry first", + UiAction::GeneratePassword if !context.editing => "Open the entry editor first", + UiAction::GeneratePassword if !context.focused_generatable => { + "Select a password or other generatable secret field first" + } + UiAction::GeneratePassword if context.switching_vault => "Wait for vault validation", + UiAction::GeneratePassword => "Close the current screen first", UiAction::ToggleReveal if !context.unlocked => "Unlock an entry first", UiAction::ToggleReveal if !context.document_open => "Open an entry first", UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first", @@ -365,6 +422,8 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { match action { UiAction::About => &["version", "license", "credits"], UiAction::Settings => &["preferences", "configuration", "config"], + UiAction::InitializeStore => &["pass init", "recipients", "gpg id"], + UiAction::NewFolder => &["create folder", "directory", "nested recipients"], UiAction::NewEntry => &["insert", "add password", "create entry"], UiAction::OpenFolder => &["open vault", "open store", "choose folder"], UiAction::OpenEntry => &["show entry", "view password"], @@ -382,6 +441,7 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { UiAction::Refresh => &["reload vault", "refresh tree"], UiAction::ReloadEntry => &["revert entry", "refresh entry"], UiAction::EditEntry => &["modify entry"], + UiAction::GeneratePassword => &["random password", "replace password", "generate"], UiAction::ToggleReveal => &["show password", "hide password", "reveal field"], UiAction::Lock => &["secure", "log out", "relock"], UiAction::Minimize => &["hide window"], @@ -439,6 +499,7 @@ mod tests { modal_open: false, focused_field: true, focused_sensitive: true, + focused_generatable: true, entry_path: true, } } @@ -464,6 +525,7 @@ mod tests { assert!(!enabled(UiAction::NewEntry, ready)); assert!(enabled(UiAction::Save, ready)); assert!(enabled(UiAction::CopyEditedField, ready)); + assert!(enabled(UiAction::GeneratePassword, ready)); assert!(!enabled(UiAction::CopyField, ready)); assert!(enabled(UiAction::ToggleReveal, ready)); @@ -474,6 +536,9 @@ mod tests { }; assert!(enabled(UiAction::CopyField, viewing)); assert!(!enabled(UiAction::CopyEditedField, viewing)); + assert!(enabled(UiAction::InitializeStore, viewing)); + assert!(enabled(UiAction::NewFolder, viewing)); + assert!(enabled(UiAction::NewEntry, viewing)); let locked = ActionContext { unlocked: false, @@ -484,6 +549,7 @@ mod tests { UiAction::CopyField, UiAction::CopyEditedField, UiAction::ToggleReveal, + UiAction::GeneratePassword, UiAction::Lock, ] { assert!(!enabled(action, locked)); @@ -523,6 +589,7 @@ mod tests { UiAction::Refresh, UiAction::ReloadEntry, UiAction::EditEntry, + UiAction::GeneratePassword, UiAction::ToggleReveal, ] { assert!(!enabled(action, switching), "{action:?}"); diff --git a/apps/desktop/src/editor.rs b/apps/desktop/src/editor.rs index b2fba3f..289b5a8 100644 --- a/apps/desktop/src/editor.rs +++ b/apps/desktop/src/editor.rs @@ -35,6 +35,21 @@ impl EntryEditor { } } + pub fn new_entry( + document: EntryDocument, + password: SecretBytes, + ) -> Result { + let mut editor = Self::new(document); + editor.add_after(None)?; + let id = editor + .focused + .expect("new entry receives its password field"); + if !password.expose().is_empty() { + editor.replace_value(id, password)?; + } + Ok(editor) + } + pub fn entry(&self) -> String { self.document.path().to_string() } diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 6b829d4..64ee61e 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -10,6 +10,7 @@ mod navigation; mod palette; use std::{ + num::NonZeroUsize, path::PathBuf, sync::{ Arc, Mutex, @@ -30,6 +31,7 @@ use ironstorage::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, + command::InitRequest, crypto::KeyInfo, desktop::{DesktopError, DesktopErrorKind, DesktopStorage}, document::{ @@ -54,6 +56,21 @@ use native_menu::NativeMenu; type OpenCompletion = Arc>>>; type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; +type CreateCompletion = Arc)>>>; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RecipientSummary { + directory: String, + recipients: usize, + reencrypted: usize, +} + +#[derive(Clone, Debug)] +struct RecipientSuccess { + storage: DesktopStorage, + key: KeyInfo, + summary: RecipientSummary, +} #[derive(Clone)] enum Message { @@ -80,6 +97,24 @@ enum Message { generation: u64, result: Box>, }, + RecipientPathChanged(String), + ToggleRecipient(usize), + SelectDefaultRecipient(usize), + ToggleRecipientConfirmation, + SubmitRecipient, + RecipientFinished { + generation: u64, + result: Box>, + }, + NewEntryPathChanged(String), + ToggleNewEntryGeneration, + NewEntryLengthChanged(String), + ToggleNewEntrySymbols, + SubmitNewEntry, + CreateFinished { + generation: u64, + completion: CreateCompletion, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -111,7 +146,10 @@ enum Message { SelectField(EntryFieldId), ToggleReveal(EntryFieldId), RequestGenerate(EntryFieldId), - ConfirmGenerate, + GenerateLengthChanged(String), + ToggleGenerateSymbols, + ToggleGenerateConfirmation, + SubmitGenerate, CancelGenerate, Copy(EntryFieldId), CopyFinished { @@ -185,6 +223,7 @@ struct App { tree_generation: u64, vault_generation: u64, settings_generation: u64, + workflow_generation: u64, panes: pane_grid::State, pane_focus: PaneFocus, navigation: NavigationTree, @@ -197,7 +236,7 @@ struct App { switching_vault: bool, confirmation: Option, after_save: Option, - generate_confirmation: Option, + generation_form: Option, conflict: bool, status: String, open_menu: Option, @@ -229,9 +268,189 @@ enum ContentMode { enum UtilityView { About, Settings(SettingsForm), + Recipients(RecipientForm), + NewEntry(NewEntryForm), Help, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RecipientWorkflowKind { + InitializeStore, + NewFolder, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RecipientChoice { + fingerprint: String, + label: String, + selected: bool, + default: bool, + can_default: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RecipientForm { + kind: RecipientWorkflowKind, + path: String, + recipients: Vec, + default_key: String, + confirmed: bool, + running: bool, + error: Option, +} + +impl RecipientForm { + fn new( + kind: RecipientWorkflowKind, + storage: &DesktopStorage, + path: String, + ) -> Result { + let default_key = storage.default_key(); + let mut recipients = storage + .key_infos() + .map_err(|error| error.to_string())? + .into_iter() + .map(|key| { + let default = key.fingerprint().as_str() == default_key; + RecipientChoice { + label: format!( + "{} — {}", + key.key_id(), + key.user_ids() + .first() + .map_or("unknown identity", String::as_str) + ), + fingerprint: key.fingerprint().to_string(), + selected: default, + default, + can_default: key.has_secret(), + } + }) + .collect::>(); + if !recipients.iter().any(|choice| choice.selected) + && let Some(first) = recipients.first_mut() + { + first.selected = true; + } + let default_key = recipients + .iter() + .find(|choice| choice.default && choice.can_default) + .or_else(|| recipients.iter().find(|choice| choice.can_default)) + .map(|choice| choice.fingerprint.clone()) + .ok_or_else(|| "No encryption key with secret key material is available.".to_owned())?; + Ok(Self { + kind, + path, + recipients, + default_key, + confirmed: false, + running: false, + error: None, + }) + } + + fn request(&self) -> Result { + let path = self.path.trim(); + if self.kind == RecipientWorkflowKind::NewFolder && path.is_empty() { + return Err("Enter a folder path.".to_owned()); + } + let key_identities = self + .recipients + .iter() + .filter(|choice| choice.selected) + .map(|choice| choice.fingerprint.clone()) + .collect::>(); + if key_identities.is_empty() { + return Err("Select at least one encryption recipient.".to_owned()); + } + if self.kind == RecipientWorkflowKind::InitializeStore + && !self.recipients.iter().any(|choice| { + choice.fingerprint == self.default_key && choice.selected && choice.can_default + }) + { + return Err("Select a recipient with secret material as the default key.".to_owned()); + } + if !self.confirmed { + return Err( + "Confirm the recipient-policy replacement and selective re-encryption.".to_owned(), + ); + } + Ok(InitRequest { + path: (!path.is_empty()).then(|| path.to_owned()), + key_identities, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NewEntryForm { + path: String, + generate: bool, + length: String, + no_symbols: bool, + running: bool, + error: Option, +} + +impl NewEntryForm { + fn new(path: String) -> Self { + Self { + path, + generate: false, + length: GeneratorConfig::pass_defaults() + .default_length() + .to_string(), + no_symbols: false, + running: false, + error: None, + } + } + + fn password(&self) -> Result { + if !self.generate { + return Ok(SecretBytes::new(Vec::new())); + } + let length = parse_generation_length(&self.length)?; + GeneratorConfig::pass_defaults() + .generate_secret(length, self.no_symbols) + .map_err(|error| error.to_string()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GenerateForm { + id: EntryFieldId, + length: String, + no_symbols: bool, + replacement: bool, + confirmed: bool, + error: Option, +} + +impl GenerateForm { + fn new(id: EntryFieldId, replacement: bool) -> Self { + Self { + id, + length: GeneratorConfig::pass_defaults() + .default_length() + .to_string(), + no_symbols: false, + replacement, + confirmed: false, + error: None, + } + } + + fn generate(&self) -> Result { + if self.replacement && !self.confirmed { + return Err("Confirm replacement of the current field value.".to_owned()); + } + GeneratorConfig::pass_defaults() + .generate_secret(parse_generation_length(&self.length)?, self.no_symbols) + .map_err(|error| error.to_string()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct SettingsForm { vault: String, @@ -287,6 +506,8 @@ enum PendingAction { OpenEntry(String), Reload(String), CloseWindow(window::Id), + ApplyRecipients(RecipientForm), + CreateEntry(NewEntryForm), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -329,6 +550,7 @@ impl App { tree_generation: 0, vault_generation: 0, settings_generation: 0, + workflow_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -346,7 +568,7 @@ impl App { switching_vault: false, confirmation: None, after_save: None, - generate_confirmation: None, + generation_form: None, conflict: false, status: "Loading shared configuration…".to_owned(), open_menu: None, @@ -408,10 +630,12 @@ impl App { } Message::PaletteInvoke(action) => return self.invoke_palette_action(action), Message::DismissUtility => { - if self.utility.as_ref().is_some_and( - |utility| matches!(utility, UtilityView::Settings(form) if form.saving), - ) { - self.status = "Wait for settings validation to finish…".to_owned(); + if self.utility.as_ref().is_some_and(|utility| { + matches!(utility, UtilityView::Settings(form) if form.saving) + || matches!(utility, UtilityView::Recipients(form) if form.running) + || matches!(utility, UtilityView::NewEntry(form) if form.running) + }) { + self.status = "Wait for the active workflow to finish…".to_owned(); } else { self.utility = None; } @@ -520,7 +744,7 @@ impl App { self.confirmation = None; self.after_save = None; self.after_authentication = None; - self.generate_confirmation = None; + self.generation_form = None; self.conflict = false; self.utility = None; self.status = "Settings saved; protected content was locked.".to_owned(); @@ -561,7 +785,7 @@ impl App { self.confirmation = None; self.after_save = None; self.after_authentication = None; - self.generate_confirmation = None; + self.generation_form = None; self.conflict = false; self.status = format!("Opened password store at {vault}."); return self.begin_tree_refresh(); @@ -572,6 +796,206 @@ impl App { } } } + Message::RecipientPathChanged(path) => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility + && !form.running + && form.kind == RecipientWorkflowKind::NewFolder + { + form.path = path; + form.error = None; + form.confirmed = false; + } + } + Message::ToggleRecipient(index) => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility + && !form.running + && let Some(recipient) = form.recipients.get_mut(index) + { + recipient.selected = !recipient.selected; + if form.kind == RecipientWorkflowKind::InitializeStore + && !recipient.selected + && recipient.fingerprint == form.default_key + { + form.default_key.clear(); + } + form.error = None; + form.confirmed = false; + } + } + Message::SelectDefaultRecipient(index) => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility + && !form.running + && form.kind == RecipientWorkflowKind::InitializeStore + && let Some(recipient) = form.recipients.get_mut(index) + && recipient.can_default + { + recipient.selected = true; + form.default_key.clone_from(&recipient.fingerprint); + form.error = None; + form.confirmed = false; + } + } + Message::ToggleRecipientConfirmation => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility + && !form.running + { + form.confirmed = !form.confirmed; + form.error = None; + } + } + Message::SubmitRecipient => { + let Some(UtilityView::Recipients(form)) = &self.utility else { + return Task::none(); + }; + if form.running { + return Task::none(); + } + if self.editor.as_ref().is_some_and(EntryEditor::is_dirty) { + if let Some(UtilityView::Recipients(form)) = &mut self.utility { + form.error = Some("Save or discard the current draft first.".to_owned()); + } + return Task::none(); + } + if let Err(error) = form.request() { + if let Some(UtilityView::Recipients(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + let form = form.clone(); + if self.handle.is_none() { + self.after_authentication = Some(PendingAction::ApplyRecipients(form)); + return self.begin_authentication(); + } + return self.begin_recipient_workflow(form); + } + Message::RecipientFinished { generation, result } => { + if generation != self.workflow_generation { + return Task::none(); + } + match *result { + Ok(summary) => { + if let Some(session) = &self.session { + let _ignored = session.manual_lock(); + } + self.authentication_generation = + self.authentication_generation.wrapping_add(1); + self.sensitive.clear(); + self.storage = Some(summary.storage); + self.key = Some(summary.key); + self.handle = None; + self.authentication = AuthenticationView::Locked; + self.utility = None; + self.editor = None; + self.content_mode = ContentMode::Viewer; + self.conflict = false; + self.status = format!( + "Recipient policy applied at {} for {} recipient(s); {} entry/entries re-encrypted.", + summary.summary.directory, + summary.summary.recipients, + summary.summary.reencrypted + ); + return self.begin_tree_refresh(); + } + Err(error) => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility { + form.running = false; + form.error = Some(error.clone()); + } + self.status = format!("Recipient workflow failed: {error}"); + } + } + } + Message::NewEntryPathChanged(path) => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility + && !form.running + { + form.path = path; + form.error = None; + } + } + Message::ToggleNewEntryGeneration => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility + && !form.running + { + form.generate = !form.generate; + form.error = None; + } + } + Message::NewEntryLengthChanged(length) => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility + && !form.running + { + form.length = length; + form.error = None; + } + } + Message::ToggleNewEntrySymbols => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility + && !form.running + { + form.no_symbols = !form.no_symbols; + form.error = None; + } + } + Message::SubmitNewEntry => { + let Some(UtilityView::NewEntry(form)) = &self.utility else { + return Task::none(); + }; + if form.running { + return Task::none(); + } + if form.path.trim().is_empty() { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.error = Some("Enter an entry path.".to_owned()); + } + return Task::none(); + } + if let Err(error) = form.password() { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + let form = form.clone(); + if self.handle.is_none() { + self.after_authentication = Some(PendingAction::CreateEntry(form)); + return self.begin_authentication(); + } + return self.begin_create_entry(form); + } + Message::CreateFinished { + generation, + completion, + } => { + let Some((password, result)) = take_completion(&completion) else { + return Task::none(); + }; + if generation != self.workflow_generation { + return Task::none(); + } + match result.and_then(|document| { + EntryEditor::new_entry(document, password).map_err(|error| error.to_string()) + }) { + Ok(editor) => { + self.entry_path = editor.entry(); + self.editor = Some(editor); + self.content_mode = ContentMode::Editor; + self.pane_focus = PaneFocus::Content; + self.utility = None; + self.conflict = false; + self.status = + "New entry draft ready; nothing is stored until Save.".to_owned(); + } + Err(error) => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.running = false; + form.error = Some(error.clone()); + } + self.status = format!("New entry failed: {error}. Store unchanged."); + } + } + } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { @@ -785,18 +1209,43 @@ impl App { .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); + self.generation_form = Some(GenerateForm::new(id, has_value)); + } + Message::GenerateLengthChanged(length) => { + if let Some(form) = &mut self.generation_form { + form.length = length; + form.error = None; } } - Message::ConfirmGenerate => { - if let Some(id) = self.generate_confirmation.take() { - self.generate(id); + Message::ToggleGenerateSymbols => { + if let Some(form) = &mut self.generation_form { + form.no_symbols = !form.no_symbols; + form.error = None; } } - Message::CancelGenerate => self.generate_confirmation = None, + Message::ToggleGenerateConfirmation => { + if let Some(form) = &mut self.generation_form { + form.confirmed = !form.confirmed; + form.error = None; + } + } + Message::SubmitGenerate => { + let Some(form) = self.generation_form.clone() else { + return Task::none(); + }; + match form.generate() { + Ok(password) => { + self.generation_form = None; + self.edit(|editor| editor.replace_value(form.id, password)); + } + Err(error) => { + if let Some(form) = &mut self.generation_form { + form.error = Some(error); + } + } + } + } + Message::CancelGenerate => self.generation_form = None, Message::Copy(id) => { if !authentication_allows_content(&self.authentication) { return Task::none(); @@ -928,11 +1377,15 @@ impl App { saving: self.saving, switching_vault: self.switching_vault, modal_open: self.confirmation.is_some() - || self.generate_confirmation.is_some() + || self.generation_form.is_some() || self.utility.is_some(), focused_field: focused.is_some(), focused_sensitive: focused .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), + focused_generatable: focused.is_some_and(|field| { + field.metadata().sensitivity() == EntrySensitivity::Sensitive + && field.metadata().kind() != EntryFieldKind::OtpUri + }), entry_path: !self.entry_path.trim().is_empty(), } } @@ -966,6 +1419,33 @@ impl App { return iced::widget::operation::focus(settings_vault_id()); } } + UiAction::InitializeStore | UiAction::NewFolder => { + let Some(storage) = &self.storage else { + return Task::none(); + }; + let kind = if action == UiAction::InitializeStore { + RecipientWorkflowKind::InitializeStore + } else { + RecipientWorkflowKind::NewFolder + }; + let path = if kind == RecipientWorkflowKind::NewFolder { + self.entry_path + .trim() + .rsplit_once('/') + .map_or_else(String::new, |(parent, _)| format!("{parent}/")) + } else { + String::new() + }; + match RecipientForm::new(kind, storage, path) { + Ok(form) => self.utility = Some(UtilityView::Recipients(form)), + Err(error) => self.status = format!("Cannot load recipients: {error}"), + } + } + UiAction::NewEntry => { + self.utility = Some(UtilityView::NewEntry(NewEntryForm::new( + self.entry_path.trim().to_owned(), + ))); + } UiAction::Help => self.utility = Some(UtilityView::Help), UiAction::CommandPalette => return self.toggle_palette(), UiAction::OpenFolder => { @@ -990,18 +1470,18 @@ impl App { UiAction::Refresh => return self.begin_tree_refresh(), UiAction::ReloadEntry => return self.update(Message::RequestReload), UiAction::EditEntry => return self.update(Message::BeginEdit), + UiAction::GeneratePassword => { + if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) { + return self.update(Message::RequestGenerate(id)); + } + } UiAction::ToggleReveal => { if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) { return self.update(Message::ToggleReveal(id)); } } UiAction::Lock => return self.update(Message::Lock), - UiAction::NewEntry - | UiAction::Undo - | UiAction::Redo - | UiAction::Cut - | UiAction::Paste - | UiAction::Find => {} + UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => {} } Task::none() } @@ -1131,9 +1611,107 @@ impl App { self.sensitive.clear(); window::close(id) } + PendingAction::ApplyRecipients(form) => self.begin_recipient_workflow(form), + PendingAction::CreateEntry(form) => self.begin_create_entry(form), } } + fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let request = match form.request() { + Ok(request) => request, + Err(error) => { + if let Some(UtilityView::Recipients(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + }; + let default_key = form.default_key.clone(); + self.workflow_generation = self.workflow_generation.wrapping_add(1); + let generation = self.workflow_generation; + if let Some(UtilityView::Recipients(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + self.status = "Applying storage-owned recipient policy…".to_owned(); + Task::perform( + async move { + let result = (|| { + let key = storage + .key_infos() + .map_err(|error| error.to_string())? + .into_iter() + .find(|key| key.fingerprint().as_str() == default_key) + .ok_or_else(|| "the selected default GPG key is unavailable".to_owned())?; + let (storage, outcome) = storage + .apply_active_recipient_policy(&handle, &request, &default_key) + .map_err(|error| error.to_string())?; + Ok(RecipientSuccess { + storage, + key, + summary: RecipientSummary { + directory: if outcome.directory().as_path().as_os_str().is_empty() { + "store root".to_owned() + } else { + outcome.directory().as_path().display().to_string() + }, + recipients: outcome.recipients().len(), + reencrypted: outcome.reencrypted_entries().len(), + }, + }) + })(); + Box::new(result) + }, + move |result| Message::RecipientFinished { generation, result }, + ) + } + + fn begin_create_entry(&mut self, form: NewEntryForm) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let entry = form.path.trim().to_owned(); + if entry.is_empty() { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.error = Some("Enter an entry path.".to_owned()); + } + return Task::none(); + } + let password = match form.password() { + Ok(password) => password, + Err(error) => { + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + }; + self.workflow_generation = self.workflow_generation.wrapping_add(1); + let generation = self.workflow_generation; + if let Some(UtilityView::NewEntry(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + self.status = format!("Preparing new entry draft for {entry}…"); + Task::perform( + async move { + Arc::new(Mutex::new(Some(( + password, + storage + .create_active_document(&handle, &entry) + .map_err(|error| error.to_string()), + )))) + }, + move |completion| Message::CreateFinished { + generation, + completion, + }, + ) + } + fn begin_settings_save(&mut self) -> Task { let Some(storage) = self.storage.clone() else { return Task::none(); @@ -1286,13 +1864,6 @@ impl App { } } - 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 navigate_viewer(&mut self, navigation: NavigationKey) -> Task { if !authentication_allows_content(&self.authentication) { return Task::none(); @@ -1346,6 +1917,7 @@ impl App { fn authentication_lost(&mut self, reason: String) { self.operation_generation = self.operation_generation.wrapping_add(1); + self.workflow_generation = self.workflow_generation.wrapping_add(1); self.handle = None; self.sensitive.clear(); self.editor = None; @@ -1354,8 +1926,19 @@ impl App { self.confirmation = None; self.after_save = None; self.after_authentication = None; - self.generate_confirmation = None; + self.generation_form = None; self.conflict = false; + match &mut self.utility { + Some(UtilityView::Recipients(form)) => { + form.running = false; + form.error = Some(reason.clone()); + } + Some(UtilityView::NewEntry(form)) => { + form.running = false; + form.error = Some(reason.clone()); + } + _ => {} + } self.authentication = AuthenticationView::Locked; self.status = reason; } @@ -1379,8 +1962,8 @@ impl App { if let Some(action) = &self.confirmation { return confirmation_view(action); } - if let Some(id) = self.generate_confirmation { - return generate_confirmation_view(id); + if let Some(form) = &self.generation_form { + return generation_view(form); } if let Some(utility) = &self.utility { return utility_view(self, utility); @@ -1629,6 +2212,122 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa content = content.push(text(format!("Settings error: {error}"))); } } + UtilityView::Recipients(form) => { + let title = match form.kind { + RecipientWorkflowKind::InitializeStore => "Initialize Password Store", + RecipientWorkflowKind::NewFolder => "Create Password Store Folder", + }; + content = content.push(text(title).size(28)); + match form.kind { + RecipientWorkflowKind::InitializeStore => { + content = content.push(text( + "Apply the root .gpg-id policy to the currently open password-store folder.", + )); + } + RecipientWorkflowKind::NewFolder => { + content = content + .push(text("Folder path")) + .push( + text_input("team/services", &form.path) + .on_input(Message::RecipientPathChanged) + .on_submit(Message::SubmitRecipient), + ) + .push(text( + "An upstream-compatible nested .gpg-id persists the folder and controls its recipients.", + )); + } + } + content = content.push(text("Encryption recipients").size(20)); + for (index, recipient) in form.recipients.iter().enumerate() { + let configured = if recipient.default { + " · previously configured default" + } else { + "" + }; + let choice = button(text(format!( + "[{}] {}{}", + if recipient.selected { "x" } else { " " }, + recipient.label, + configured + ))); + let choice = if form.running { + choice + } else { + choice.on_press(Message::ToggleRecipient(index)) + }; + if form.kind == RecipientWorkflowKind::InitializeStore { + let selected_default = recipient.fingerprint == form.default_key; + let default = button(text(if !recipient.can_default { + "Public recipient only" + } else if selected_default { + "Default key ✓" + } else { + "Use as default key" + })); + let default = if form.running || !recipient.can_default { + default + } else { + default.on_press(Message::SelectDefaultRecipient(index)) + }; + content = content.push(row![choice, default].spacing(8)); + } else { + content = content.push(choice); + } + } + let confirmation = button(text(format!( + "[{}] Replace this recipient policy and selectively re-encrypt affected entries", + if form.confirmed { "x" } else { " " } + ))); + content = content.push(if form.running { + confirmation + } else { + confirmation.on_press(Message::ToggleRecipientConfirmation) + }); + if let Some(error) = &form.error { + content = content.push(text(format!("Recipient error: {error}"))); + } + } + UtilityView::NewEntry(form) => { + content = content + .push(text("New Entry").size(28)) + .push(text("Entry path")) + .push( + text_input("folder/account", &form.path) + .on_input(Message::NewEntryPathChanged) + .on_submit(Message::SubmitNewEntry), + ); + let generate = button(text(format!( + "[{}] Generate the initial password with storage policy", + if form.generate { "x" } else { " " } + ))); + content = content.push(if form.running { + generate + } else { + generate.on_press(Message::ToggleNewEntryGeneration) + }); + if form.generate { + content = content.push(text("Password length")).push( + text_input("25", &form.length) + .on_input(Message::NewEntryLengthChanged) + .on_submit(Message::SubmitNewEntry), + ); + let symbols = button(text(format!( + "[{}] Letters and digits only", + if form.no_symbols { "x" } else { " " } + ))); + content = content.push(if form.running { + symbols + } else { + symbols.on_press(Message::ToggleNewEntrySymbols) + }); + } + content = content.push(text( + "Create opens a structured unsaved draft. Encryption and persistence happen only when Save succeeds.", + )); + if let Some(error) = &form.error { + content = content.push(text(format!("New entry error: {error}"))); + } + } UtilityView::Help => { content = content .push(text("IronStorage Help").size(28)) @@ -1666,7 +2365,10 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } let done = button("Done (Esc)"); - let done = if matches!(utility, UtilityView::Settings(form) if form.saving) { + let busy = matches!(utility, UtilityView::Settings(form) if form.saving) + || matches!(utility, UtilityView::Recipients(form) if form.running) + || matches!(utility, UtilityView::NewEntry(form) if form.running); + let done = if busy { done } else { done.on_press(Message::DismissUtility) @@ -1688,6 +2390,38 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa ] .spacing(8) } + UtilityView::Recipients(form) => { + let apply = button(if form.running { + "Applying…" + } else { + "Apply Recipient Policy" + }); + row![ + if form.running { + apply + } else { + apply.on_press(Message::SubmitRecipient) + }, + done, + ] + .spacing(8) + } + UtilityView::NewEntry(form) => { + let create = button(if form.running { + "Preparing…" + } else { + "Create Draft" + }); + row![ + if form.running { + create + } else { + create.on_press(Message::SubmitNewEntry) + }, + done, + ] + .spacing(8) + } UtilityView::About | UtilityView::Help => row![done], }; container( @@ -1713,6 +2447,8 @@ fn sidebar_view<'a>( "Password Store" }) .size(20), + button("Initialize…").on_press(Message::Action(UiAction::InitializeStore)), + button("New Folder…").on_press(Message::Action(UiAction::NewFolder)), button("Refresh").on_press(Message::Action(UiAction::Refresh)), ] .spacing(8) @@ -1793,6 +2529,7 @@ fn content_view(app: &App) -> Element<'_, Message> { .on_input(Message::EntryPathChanged) .on_submit(Message::Action(UiAction::OpenEntry)), button("Open").on_press(Message::Action(UiAction::OpenEntry)), + button("New Entry…").on_press(Message::Action(UiAction::NewEntry)), button("Open Folder…").on_press(Message::Action(UiAction::OpenFolder)), button("Reload").on_press(Message::Action(UiAction::ReloadEntry)), button("Lock").on_press(Message::Action(UiAction::Lock)), @@ -2048,7 +2785,9 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { ); } if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { - actions = actions.push(button("Generate").on_press(Message::RequestGenerate(id))); + actions = actions.push( + button("Generate").on_press(Message::FieldAction(id, UiAction::GeneratePassword)), + ); } fields = fields.push(column![text(label).size(14), input, actions].spacing(4)); } @@ -2061,6 +2800,11 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { PendingAction::OpenEntry(entry) => format!("Open {entry}"), PendingAction::Reload(entry) => format!("Reload {entry}"), PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(), + PendingAction::ApplyRecipients(form) => match form.kind { + RecipientWorkflowKind::InitializeStore => "Initialize the store".to_owned(), + RecipientWorkflowKind::NewFolder => format!("Create folder {}", form.path), + }, + PendingAction::CreateEntry(form) => format!("Create entry {}", form.path), }; container( column![ @@ -2081,22 +2825,62 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { .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."), +fn generation_view(form: &GenerateForm) -> Element<'_, Message> { + let mut content = column![ + text(if form.replacement { + "Replace with Generated Password" + } else { + "Generate Password" + }) + .size(26), + text("Password length"), + text_input("25", &form.length) + .on_input(Message::GenerateLengthChanged) + .on_submit(Message::SubmitGenerate), + button(text(format!( + "[{}] Letters and digits only", + if form.no_symbols { "x" } else { " " } + ))) + .on_press(Message::ToggleGenerateSymbols), + ] + .spacing(12); + if form.replacement { + content = content.push( + button(text(format!( + "[{}] Replace the current field value", + if form.confirmed { "x" } else { " " } + ))) + .on_press(Message::ToggleGenerateConfirmation), + ); + } + if let Some(error) = &form.error { + content = content.push(text(format!("Generation error: {error}"))); + } + content = content + .push(text(format!("Field {}", form.id.value())).size(12)) + .push( row![ - button("Replace").on_press(Message::ConfirmGenerate), + button(if form.replacement { + "Generate and Replace" + } else { + "Generate" + }) + .on_press(Message::SubmitGenerate), button("Cancel").on_press(Message::CancelGenerate), ] .spacing(8), - text(format!("Field {}", id.value())).size(12), - ] - .spacing(12), - ) - .center(Length::Fill) - .into() + ); + container(content).center(Length::Fill).into() +} + +fn parse_generation_length(value: &str) -> Result, String> { + let length = value + .trim() + .parse::() + .map_err(|_| "Password length must be a positive whole number.".to_owned())?; + NonZeroUsize::new(length) + .map(Some) + .ok_or_else(|| "Password length must be greater than zero.".to_owned()) } async fn load_authentication() @@ -2220,7 +3004,8 @@ mod tests { use ironstorage::{ authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, crypto::{KeyStore, SecretProvider, SecretProviderError}, - repository::EntryPath, + git::{GitIdentity, GitRepository}, + repository::{EntryPath, Repository}, secret_store::{ SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreError, @@ -2395,6 +3180,7 @@ mod tests { tree_generation: 0, vault_generation: 0, settings_generation: 0, + workflow_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -2412,7 +3198,7 @@ mod tests { switching_vault: false, confirmation: None, after_save: None, - generate_confirmation: None, + generation_form: None, conflict: false, status: String::new(), open_menu: None, @@ -2904,4 +3690,255 @@ mod tests { ); assert!(app.navigation.is_empty()); } + + #[test] + fn recipient_forms_round_trip_storage_keys_and_create_upstream_policies() { + let (_temporary, storage) = fixture_storage(); + let mut root = RecipientForm::new( + RecipientWorkflowKind::InitializeStore, + &storage, + String::new(), + ) + .expect("root initialization form"); + assert!(root.recipients.len() >= 2); + let replacement_default = root + .recipients + .iter_mut() + .find(|recipient| recipient.can_default && !recipient.default) + .expect("second secret encryption key"); + replacement_default.selected = true; + root.default_key + .clone_from(&replacement_default.fingerprint); + root.confirmed = true; + let root_request = root.request().expect("root initialization request"); + fs::remove_file(storage.vault().join(".gpg-id")).expect("empty uninitialized store"); + let repository = Repository::open(storage.vault()).expect("repository"); + let git = GitRepository::init(&repository, GitIdentity::ironstorage()).expect("Git init"); + let commits_before = git.log(None).expect("initial log").len(); + let (initialized, root_outcome) = storage + .apply_recipient_policy(&root_request, &root.default_key, &mut FixtureSecrets) + .expect("root initialization"); + assert!(root_outcome.directory().as_path().as_os_str().is_empty()); + let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("reopen Git"); + assert_eq!( + git.log(None).expect("root init log").len(), + commits_before + 1 + ); + assert!(git.status().expect("root init status").is_clean()); + assert_eq!( + fs::read(storage.vault().join(".gpg-id")).expect("root policy bytes"), + root_request + .key_identities + .iter() + .map(|identity| format!("{identity}\n")) + .collect::() + .as_bytes() + ); + assert_eq!(initialized.default_key(), root.default_key); + assert_eq!( + DesktopStorage::load(Some(storage.config_source())) + .expect("reloaded config") + .default_key(), + root.default_key + ); + + let mut form = RecipientForm::new( + RecipientWorkflowKind::NewFolder, + &initialized, + "team/services".to_owned(), + ) + .expect("recipient form"); + assert!(form.recipients.len() >= 2); + let default = form + .recipients + .iter() + .find(|recipient| recipient.default) + .expect("configured default key"); + assert!(default.selected); + assert!(form.request().is_err(), "replacement requires confirmation"); + form.confirmed = true; + let request = form.request().expect("validated request"); + let (updated, outcome) = initialized + .apply_recipient_policy(&request, &form.default_key, &mut FixtureSecrets) + .expect("nested recipient policy"); + assert_eq!(outcome.directory().as_path(), Path::new("team/services")); + assert_eq!(outcome.recipients().len(), 1); + assert_eq!(updated.default_key(), form.default_key); + assert_eq!( + DesktopStorage::load(Some(initialized.config_source())) + .expect("reloaded config") + .default_key(), + form.default_key + ); + assert_eq!( + fs::read(storage.vault().join("team/services/.gpg-id")).expect("policy bytes"), + request + .key_identities + .iter() + .map(|identity| format!("{identity}\n")) + .collect::() + .as_bytes() + ); + + form.path = "../escape".to_owned(); + let invalid = form.request().expect("UI leaves path rules to storage"); + assert!( + initialized + .apply_recipient_policy(&invalid, &form.default_key, &mut FixtureSecrets) + .is_err() + ); + assert!(!storage.vault().join("escape").exists()); + } + + #[test] + fn new_entry_drafts_generate_before_save_and_reject_collisions() { + let (_temporary, storage) = fixture_storage(); + let form = NewEntryForm { + path: "generated/account".to_owned(), + generate: true, + length: "32".to_owned(), + no_symbols: true, + running: false, + error: None, + }; + let password = form.password().expect("generated password"); + assert_eq!(password.expose().len(), 32); + assert!(password.expose().iter().all(u8::is_ascii_alphanumeric)); + let document = storage + .create_document(&form.path, &mut FixtureSecrets) + .expect("new document"); + assert!(document.fields().is_empty()); + assert!(!storage.vault().join("generated/account.gpg").exists()); + + let editor = EntryEditor::new_entry(document, password).expect("new entry editor"); + assert!(editor.is_dirty()); + assert_eq!(editor.fields().len(), 1); + assert_eq!( + editor.fields()[0].metadata().kind(), + EntryFieldKind::Password + ); + save_document(&storage, &editor).expect("save generated entry"); + assert!(storage.vault().join("generated/account.gpg").is_file()); + let collision = storage + .create_document(&form.path, &mut FixtureSecrets) + .expect_err("existing entry must not become a draft"); + assert_eq!(collision.kind(), DesktopErrorKind::EntryExists); + } + + #[test] + fn generated_replacements_confirm_and_update_only_the_selected_draft_field() { + let (_temporary, storage) = fixture_storage(); + let mut editor = empty_editor(&storage, "replacement"); + editor.add_after(None).expect("password field"); + let password = editor.fields()[0].id(); + editor.update_raw(password, b"old password").expect("value"); + editor.add_after(Some(password)).expect("username field"); + let username = editor.fields()[1].id(); + editor + .update_raw(username, b"username: alice") + .expect("username"); + let username_before = editor.fields()[1].contents().expose().to_vec(); + let mut app = test_app(Some(editor)); + app.authentication = AuthenticationView::Unlocked(Duration::from_secs(60)); + app.content_mode = ContentMode::Editor; + + let _task = app.update(Message::FieldAction(password, UiAction::GeneratePassword)); + assert!( + app.generation_form + .as_ref() + .is_some_and(|form| form.replacement) + ); + let _task = app.update(Message::SubmitGenerate); + assert!( + app.generation_form + .as_ref() + .is_some_and(|form| form.error.is_some()) + ); + assert_eq!( + app.editor.as_ref().expect("draft").fields()[0].value(), + b"old password" + ); + + let _task = app.update(Message::GenerateLengthChanged("12".to_owned())); + let _task = app.update(Message::ToggleGenerateSymbols); + let _task = app.update(Message::ToggleGenerateConfirmation); + let _task = app.update(Message::SubmitGenerate); + let editor = app.editor.as_ref().expect("updated draft"); + assert!(app.generation_form.is_none()); + assert_eq!(editor.fields()[0].value().len(), 12); + assert!( + editor.fields()[0] + .value() + .iter() + .all(u8::is_ascii_alphanumeric) + ); + assert_eq!( + editor + .document() + .field(username) + .unwrap() + .contents() + .expose(), + username_before + ); + } + + #[test] + fn creation_and_generation_state_preserve_drafts_on_cancel_or_failure() { + let (_temporary, storage) = fixture_storage(); + let mut existing = empty_editor(&storage, "existing"); + existing.add_after(None).expect("existing draft field"); + let field_id = existing.fields()[0].id(); + let mut app = test_app(Some(existing)); + app.storage = Some(storage.clone()); + app.utility = Some(UtilityView::NewEntry(NewEntryForm::new( + "cancelled".to_owned(), + ))); + let _task = app.update(Message::DismissUtility); + assert!(app.utility.is_none()); + assert!(app.editor.is_some()); + assert!(!storage.vault().join("cancelled.gpg").exists()); + + let policy_before = fs::read(storage.vault().join(".gpg-id")).expect("policy before"); + app.utility = Some(UtilityView::Recipients( + RecipientForm::new( + RecipientWorkflowKind::InitializeStore, + &storage, + String::new(), + ) + .expect("recipient form"), + )); + let _task = app.update(Message::DismissUtility); + assert!(app.editor.is_some()); + assert_eq!( + fs::read(storage.vault().join(".gpg-id")).expect("policy after cancel"), + policy_before + ); + + app.utility = Some(UtilityView::NewEntry(NewEntryForm::new( + "failed".to_owned(), + ))); + app.workflow_generation = 4; + let completion = Arc::new(Mutex::new(Some(( + SecretBytes::new(Vec::new()), + Err("injected creation failure".to_owned()), + )))); + let _task = app.update(Message::CreateFinished { + generation: 4, + completion, + }); + assert!(app.editor.is_some()); + assert!(matches!( + app.utility, + Some(UtilityView::NewEntry(NewEntryForm { error: Some(_), .. })) + )); + + let mut generate = GenerateForm::new(field_id, true); + assert!(generate.generate().is_err()); + generate.confirmed = true; + generate.length = "0".to_owned(); + assert!(generate.generate().is_err()); + generate.length = "16".to_owned(); + assert_eq!(generate.generate().expect("replacement").expose().len(), 16); + } } diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 47f42ad..99b4044 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -130,12 +130,15 @@ fn accelerator(action: UiAction) -> Option { UiAction::Lock => (command, Code::KeyL), UiAction::Help => (Modifiers::empty(), Code::F1), UiAction::About + | UiAction::InitializeStore + | UiAction::NewFolder | UiAction::CopyField | UiAction::CopyEditedField | UiAction::TogglePaneFocus | UiAction::OpenEntry | UiAction::ReloadEntry | UiAction::EditEntry + | UiAction::GeneratePassword | UiAction::ToggleReveal | UiAction::Minimize => return None, }; diff --git a/apps/desktop/src/palette.rs b/apps/desktop/src/palette.rs index 1bc9df0..84d7359 100644 --- a/apps/desktop/src/palette.rs +++ b/apps/desktop/src/palette.rs @@ -156,6 +156,16 @@ mod tests { assert_eq!(matches("setings").first(), Some(&UiAction::Settings)); assert_eq!(matches("rfsh").first(), Some(&UiAction::Refresh)); assert_eq!(matches("open vault").first(), Some(&UiAction::OpenFolder)); + assert_eq!( + matches("pass init").first(), + Some(&UiAction::InitializeStore) + ); + assert_eq!(matches("create folder").first(), Some(&UiAction::NewFolder)); + assert_eq!(matches("insert").first(), Some(&UiAction::NewEntry)); + assert_eq!( + matches("generate").first(), + Some(&UiAction::GeneratePassword) + ); assert_eq!( matches(""), action::ACTIONS diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index b17f65d..b863e7f 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -6,15 +6,20 @@ use crate::{ authentication::{ AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession, }, + command::InitRequest, config::{Config, ConfigSettings, EditorCommand}, crypto::{KeyInfo, KeyStore, SecretProvider}, document::{DocumentError, EntryDocument, EntryDocumentService}, - git::{AutomaticEntryCommitter, GitIdentity}, + git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity}, presentation::ClipboardTimeout, read::{TreeModel, VaultReader}, + recipient::{ + PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager, + RecipientPolicyOutcome, + }, repository::{DirectoryPath, Repository}, secret_store::SecretProtectionPolicy, - write::{WriteError, WriteOutcome}, + write::{VaultWriter, WriteError, WriteOutcome}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -29,6 +34,7 @@ pub enum DesktopErrorKind { Conflict, Unchanged, MissingDefaultKey, + EntryExists, } #[derive(Debug)] @@ -193,6 +199,109 @@ impl DesktopStorage { .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } + /// Enumerate storage-validated encryption keys for recipient selection. + pub fn key_infos(&self) -> Result, DesktopError> { + Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect()) + } + + /// Apply an upstream-compatible root or nested `.gpg-id` policy while the + /// shared authentication lease is active. + pub fn apply_active_recipient_policy( + &self, + handle: &NativeAuthenticationHandle, + request: &InitRequest, + default_key: &str, + ) -> Result<(Self, RecipientPolicyOutcome), DesktopError> { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.apply_recipient_policy(request, default_key, &mut provider) + } + + pub fn apply_recipient_policy( + &self, + request: &InitRequest, + default_key: &str, + provider: &mut impl SecretProvider, + ) -> Result<(Self, RecipientPolicyOutcome), DesktopError> { + let repository = self.repository()?; + let keys = self.keys()?; + let default = keys + .infos() + .find(|key| key.fingerprint().as_str() == default_key) + .ok_or_else(|| { + DesktopError::new( + DesktopErrorKind::MissingDefaultKey, + "the selected default GPG key is unavailable", + ) + })?; + if !default.has_secret() { + return Err(DesktopError::new( + DesktopErrorKind::MissingDefaultKey, + "the selected default GPG key has no secret key material", + )); + } + let mut settings = self.settings(); + settings.set_default_key(default_key.to_owned()); + let replacement = self.validate_settings(settings)?; + let directory = request.path.as_deref().unwrap_or_default(); + let committer = AutomaticPolicyCommitter::for_directory( + &repository, + directory, + GitIdentity::ironstorage(), + ) + .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; + let mut committer = ConfigPolicyCommitter { + git: committer, + previous: &self.config, + replacement: &replacement.config, + persisted: false, + }; + let outcome = RecipientPolicyManager::new(&repository, &keys) + .apply_init(request, None, provider, &mut committer) + .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?; + if !committer.persisted { + replacement.persist()?; + } + Ok((replacement, outcome)) + } + + /// Open a collision-free in-memory draft. No repository bytes are written + /// until the structured document is explicitly saved. + pub fn create_active_document( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.create_document(entry, &mut provider) + } + + pub fn create_document( + &self, + entry: &str, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + if VaultWriter::new(&repository, &keys) + .entry_exists(entry) + .map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))? + { + return Err(DesktopError::new( + DesktopErrorKind::EntryExists, + format!("password-store entry already exists: {entry}"), + )); + } + EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document) + } + pub fn open_document( &self, entry: &str, @@ -239,6 +348,33 @@ impl DesktopStorage { } } +struct ConfigPolicyCommitter<'a> { + git: AutomaticPolicyCommitter, + previous: &'a Config, + replacement: &'a Config, + persisted: bool, +} + +impl PolicyCommitter for ConfigPolicyCommitter<'_> { + fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { + self.replacement + .persist() + .map_err(|error| PolicyCommitError::new(error.to_string()))?; + match self.git.commit(change) { + Ok(()) => { + self.persisted = true; + Ok(()) + } + Err(operation) => match self.previous.persist() { + Ok(()) => Err(operation), + Err(rollback) => Err(PolicyCommitError::new(format!( + "recipient-policy commit failed ({operation}) and configuration rollback failed ({rollback})" + ))), + }, + } + } +} + pub struct DesktopBootstrap { storage: DesktopStorage, authentication: NativeAuthenticationSession, diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index ecbb884..bce0792 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -938,8 +938,11 @@ impl GitRepository { path: &Path, identity: GitIdentity, ) -> Result { - let relative = validate_relative(path)?; - let mut candidate = store.root_path().join(relative); + let mut candidate = if path.as_os_str().is_empty() { + store.root_path().to_owned() + } else { + store.root_path().join(validate_relative(path)?) + }; if !candidate.is_dir() { candidate.pop(); } diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index 40b7ba7..0102298 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -103,6 +103,8 @@ fn nested_repository_selection_is_innermost() -> TestResult { let temporary = tempfile::tempdir()?; let outer_store = Repository::open(temporary.path())?; GitRepository::init(&outer_store, identity())?; + let root = GitRepository::open_innermost(&outer_store, Path::new(""), identity())?; + assert_eq!(root.root(), fs::canonicalize(temporary.path())?); fs::create_dir(temporary.path().join("nested"))?; let inner_store = Repository::open(temporary.path().join("nested"))?; GitRepository::init(&inner_store, identity())?;