diff --git a/Cargo.lock b/Cargo.lock index 28fe17a..75a16f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,6 +806,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1194,6 +1200,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -2172,6 +2184,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -2556,6 +2574,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3971,6 +3999,7 @@ dependencies = [ "iced_core", "iced_futures", "log", + "lyon_path", "raw-window-handle", "rustc-hash 2.1.3", "thiserror 2.0.19", @@ -4045,6 +4074,7 @@ dependencies = [ "iced_debug", "iced_graphics", "log", + "lyon", "rustc-hash 2.1.3", "thiserror 2.0.19", "wgpu", @@ -4201,6 +4231,23 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -4289,6 +4336,7 @@ dependencies = [ "gix-config", "hex", "hmac", + "image", "keyring-core", "nix 0.31.3", "pgp", @@ -4745,6 +4793,58 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + [[package]] name = "mac_address" version = "1.1.8" @@ -4859,6 +4959,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -6169,6 +6279,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qrcode" version = "0.14.1" @@ -8641,6 +8757,12 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -9610,6 +9732,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.13.1" diff --git a/Cargo.toml b/Cargo.toml index ad63563..2f3a840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,8 @@ flate2 = "1.1" gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] } gix-config = "0.59" hmac = "0.12" -iced = { version = "0.14", features = ["tokio"] } +iced = { version = "0.14", features = ["canvas", "tokio"] } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } ironstorage = { path = "crates/storage" } keyring-core = "1.0" muda = "0.19" diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index 118035a..1a351d0 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -32,6 +32,13 @@ pub enum UiAction { CopyEntry, DeleteEntry, ToggleReveal, + GenerateOtp, + CopyOtp, + ImportOtp, + ShowOtpUri, + CopyOtpUri, + ShowOtpQr, + RemoveOtp, GitStatus, GitPull, GitPush, @@ -72,6 +79,13 @@ impl UiAction { Self::CopyEntry => "copy-entry", Self::DeleteEntry => "delete-entry", Self::ToggleReveal => "toggle-reveal", + Self::GenerateOtp => "generate-otp", + Self::CopyOtp => "copy-otp", + Self::ImportOtp => "import-otp", + Self::ShowOtpUri => "show-otp-uri", + Self::CopyOtpUri => "copy-otp-uri", + Self::ShowOtpQr => "show-otp-qr", + Self::RemoveOtp => "remove-otp", Self::GitStatus => "git-status", Self::GitPull => "git-pull", Self::GitPush => "git-push", @@ -135,6 +149,7 @@ pub struct ActionContext { pub focused_field: bool, pub focused_sensitive: bool, pub focused_generatable: bool, + pub focused_otp: bool, pub entry_path: bool, pub selected_object: bool, pub git_running: bool, @@ -237,6 +252,23 @@ pub const ACTIONS: &[ActionSpec] = &[ "Reveal or Hide Field", None, ), + spec( + UiAction::GenerateOtp, + MenuGroup::Entry, + "Generate OTP Code", + None, + ), + spec(UiAction::CopyOtp, MenuGroup::Entry, "Copy OTP Code", None), + spec( + UiAction::ImportOtp, + MenuGroup::Entry, + "Import OTP URI or QR…", + None, + ), + spec(UiAction::ShowOtpUri, MenuGroup::Entry, "Show OTP URI", None), + spec(UiAction::CopyOtpUri, MenuGroup::Entry, "Copy OTP URI", None), + spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None), + spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None), spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None), spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None), spec(UiAction::GitPush, MenuGroup::Tools, "Push", None), @@ -378,6 +410,28 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.switching_vault && context.focused_sensitive } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp => { + context.unlocked + && context.document_open + && !context.editing + && context.focused_otp + && !context.saving + && !context.switching_vault + && !context.modal_open + } + UiAction::ImportOtp => { + context.storage_ready + && context.unlocked + && !context.dirty + && !context.saving + && !context.switching_vault + && !context.modal_open + } UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => { context.storage_ready && !context.saving @@ -497,6 +551,58 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta UiAction::ToggleReveal if !context.document_open => "Open an entry first", UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first", UiAction::ToggleReveal => "Wait for vault validation", + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp + if !context.unlocked => + { + "Unlock an entry first" + } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp + if !context.document_open => + { + "Open an entry first" + } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp + if !context.focused_otp => + { + "Select an OTP URI field first" + } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp + if context.editing => + { + "Finish editing the entry first" + } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp => "Close the current screen first", + UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable", + UiAction::ImportOtp if !context.unlocked => "Unlock the password store first", + UiAction::ImportOtp if context.dirty => "Save or discard the current draft first", + UiAction::ImportOtp if context.saving => "Wait for the active save", + UiAction::ImportOtp if context.switching_vault => "Wait for vault validation", + UiAction::ImportOtp => "Close the current screen first", UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync if !context.storage_ready => { @@ -561,6 +667,13 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"], UiAction::DeleteEntry => &["remove", "rm", "delete folder"], UiAction::ToggleReveal => &["show password", "hide password", "reveal field"], + UiAction::GenerateOtp => &["totp", "hotp", "one time password"], + UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"], + UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"], + UiAction::ShowOtpUri => &["show otpauth", "otp secret"], + UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"], + UiAction::ShowOtpQr => &["otp qr", "export otp"], + UiAction::RemoveOtp => &["delete otp", "remove otpauth"], UiAction::GitStatus => &["repository status", "git history"], UiAction::GitPull => &["fetch", "download changes"], UiAction::GitPush => &["upload changes"], @@ -623,6 +736,7 @@ mod tests { focused_field: true, focused_sensitive: true, focused_generatable: true, + focused_otp: true, entry_path: true, selected_object: true, git_running: false, @@ -672,6 +786,17 @@ mod tests { assert!(enabled(UiAction::InitializeStore, viewing)); assert!(enabled(UiAction::NewFolder, viewing)); assert!(enabled(UiAction::NewEntry, viewing)); + for action in [ + UiAction::GenerateOtp, + UiAction::CopyOtp, + UiAction::ShowOtpUri, + UiAction::CopyOtpUri, + UiAction::ShowOtpQr, + UiAction::RemoveOtp, + ] { + assert!(enabled(action, viewing), "{action:?}"); + } + assert!(enabled(UiAction::ImportOtp, viewing)); let locked = ActionContext { unlocked: false, @@ -683,6 +808,13 @@ mod tests { UiAction::CopyEditedField, UiAction::ToggleReveal, UiAction::GeneratePassword, + UiAction::GenerateOtp, + UiAction::CopyOtp, + UiAction::ImportOtp, + UiAction::ShowOtpUri, + UiAction::CopyOtpUri, + UiAction::ShowOtpQr, + UiAction::RemoveOtp, UiAction::Lock, ] { assert!(!enabled(action, locked)); diff --git a/apps/desktop/src/folder_picker.rs b/apps/desktop/src/folder_picker.rs index b4eb274..a0a8bd8 100644 --- a/apps/desktop/src/folder_picker.rs +++ b/apps/desktop/src/folder_picker.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; +use ironstorage::repository::SecretBytes; + #[cfg(any(target_os = "macos", target_os = "windows"))] pub async fn pick_folder(initial: Option) -> Result, String> { let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store"); @@ -14,6 +16,22 @@ pub async fn pick_folder(initial: Option) -> Result, St .map(|folder| folder.path().to_owned())) } +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub async fn pick_qr_image() -> Result, String> { + let selected = rfd::AsyncFileDialog::new() + .set_title("Import OTP QR Image") + .add_filter("Image", &["png", "jpg", "jpeg", "gif"]) + .pick_file() + .await; + selected + .map(|file| { + std::fs::read(file.path()) + .map(SecretBytes::new) + .map_err(|error| error.to_string()) + }) + .transpose() +} + #[cfg(target_os = "linux")] pub async fn pick_folder(initial: Option) -> Result, String> { use ashpd::{ @@ -47,6 +65,37 @@ pub async fn pick_folder(initial: Option) -> Result, St .map(Some) } +#[cfg(target_os = "linux")] +pub async fn pick_qr_image() -> Result, String> { + use ashpd::{ + PortalError, + desktop::{file_chooser::SelectedFiles, request::ResponseError}, + }; + + let response = SelectedFiles::open_file() + .title("Import OTP QR Image") + .accept_label("Import") + .modal(true) + .multiple(false) + .send() + .await + .and_then(|request| request.response()); + let selected = match response { + Ok(selected) => selected, + Err(ashpd::Error::Response(ResponseError::Cancelled)) + | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let Some(uri) = selected.uris().first() else { + return Err("file portal returned no selection".to_owned()); + }; + let path = file_uri_path(uri.as_str())?; + std::fs::read(path) + .map(SecretBytes::new) + .map(Some) + .map_err(|error| error.to_string()) +} + #[cfg(target_os = "linux")] fn file_uri_path(uri: &str) -> Result { let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?; diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index d0a2c59..909707d 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -17,13 +17,16 @@ use std::{ atomic::{AtomicBool, Ordering}, }, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use editor::{EntryEditor, FieldNavigation}; use iced::{ - Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, - widget::{button, column, container, mouse_area, pane_grid, row, scrollable, text, text_input}, + Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, Task, Theme, + event, keyboard, mouse, time, touch, + widget::{ + button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, text_input, + }, window, }; use ironstorage::{ @@ -35,7 +38,8 @@ use ironstorage::{ crypto::KeyInfo, desktop::{ DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult, - DesktopMutationRequest, DesktopStorage, + DesktopMutationRequest, DesktopOtpCode, DesktopOtpMetadata, DesktopOtpMutation, + DesktopOtpUri, DesktopStorage, }, document::{ DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, @@ -47,7 +51,8 @@ use ironstorage::{ GitProgressPhase, GitSnapshot, }, mutation::{MutationAction, MutationOutcome, MutationSelection}, - presentation::{ClipboardWait, NativeClipboardManager}, + otp::{OtpCodeValidity, OtpKind}, + presentation::{ClipboardWait, NativeClipboardManager, QrMatrix}, read::{FindResults, GrepResults, TreeModel, TreeNodeId}, repository::SecretBytes, secret_store::SecretStoreBackend, @@ -68,6 +73,8 @@ type CreateCompletion = Arc>>>; type GitCompletion = Arc>>>; type GitProgress = Arc>>; +type OtpCompletion = Arc>>>; +type SecretInputCompletion = Arc, String>>>>; #[derive(Clone, Debug, Eq, PartialEq)] struct RecipientSummary { @@ -156,6 +163,24 @@ enum Message { generation: u64, completion: GitCompletion, }, + OtpEntryChanged(String), + OtpUriChanged(Zeroizing), + ToggleOtpReplace, + ToggleOtpRemovalConfirmation, + PickOtpQr, + OtpQrPicked(SecretInputCompletion), + SubmitOtpImport, + SubmitOtpRemoval, + RunOtpCode(bool), + RunOtpUri { + qr: bool, + copy: bool, + }, + ConfirmHotp, + OtpFinished { + generation: u64, + completion: OtpCompletion, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -228,20 +253,34 @@ enum AuthenticationView { struct SensitiveUiState { clipboard_cancel: Option>, clipboard_generation: u64, + clipboard_deadline: Option, + otp: Option, + otp_uri: Option, + otp_qr: Option, } impl SensitiveUiState { fn clear(&mut self) { self.clipboard_generation = self.clipboard_generation.wrapping_add(1); + self.cancel_clipboard(); + self.otp = None; + self.otp_uri = None; + self.otp_qr = None; + } + + fn cancel_clipboard(&mut self) { if let Some(cancel) = self.clipboard_cancel.take() { cancel.store(true, Ordering::Release); } + self.clipboard_deadline = None; } - fn begin_copy(&mut self) -> (u64, Arc) { - self.clear(); + fn begin_copy(&mut self, timeout: Duration) -> (u64, Arc) { + self.clipboard_generation = self.clipboard_generation.wrapping_add(1); + self.cancel_clipboard(); let cancel = Arc::new(AtomicBool::new(false)); self.clipboard_cancel = Some(Arc::clone(&cancel)); + self.clipboard_deadline = Some(Instant::now() + timeout); (self.clipboard_generation, cancel) } @@ -250,8 +289,18 @@ impl SensitiveUiState { return false; } self.clipboard_cancel = None; + self.clipboard_deadline = None; true } + + fn clipboard_remaining(&self, now: Instant) -> Option { + self.clipboard_deadline.map(|deadline| { + let remaining = deadline.saturating_duration_since(now); + remaining + .as_secs() + .saturating_add(u64::from(remaining.subsec_nanos() != 0)) + }) + } } struct App { @@ -269,6 +318,8 @@ struct App { vault_generation: u64, settings_generation: u64, workflow_generation: u64, + otp_generation: u64, + otp_pending: bool, selection_after_refresh: Option, panes: pane_grid::State, pane_focus: PaneFocus, @@ -319,9 +370,66 @@ enum UtilityView { Search(SearchForm), Mutation(MutationForm), Git(GitForm), + Otp(OtpForm), Help, } +struct OtpDisplay { + entry: String, + code: SecretBytes, + validity: OtpCodeValidity, + metadata: DesktopOtpMetadata, + observed_at: u64, +} + +impl OtpDisplay { + fn remaining_at(&self, unix_seconds: u64) -> Option { + self.validity.remaining_at(unix_seconds) + } +} + +struct OtpForm { + entry: String, + uri: Zeroizing, + replace: bool, + remove_confirmed: bool, + copy_after_code: bool, + hotp_confirmation: bool, + running: bool, + error: Option, +} + +impl OtpForm { + fn new(entry: String) -> Self { + Self { + entry, + uri: Zeroizing::new(String::new()), + replace: false, + remove_confirmed: false, + copy_after_code: false, + hotp_confirmation: false, + running: false, + error: None, + } + } +} + +enum OtpTaskResult { + Code { + entry: String, + copy: bool, + observed_at: u64, + outcome: DesktopOtpCode, + }, + Uri { + entry: String, + copy: bool, + outcome: DesktopOtpUri, + }, + Imported(DesktopOtpMutation), + Removed(DesktopOtpMutation), +} + #[derive(Clone, Debug)] struct GitConflictSelection { conflict: GitConflict, @@ -769,6 +877,8 @@ impl App { vault_generation: 0, settings_generation: 0, workflow_generation: 0, + otp_generation: 0, + otp_pending: false, selection_after_refresh: None, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, @@ -857,9 +967,17 @@ impl App { || matches!(utility, UtilityView::Search(form) if form.running) || matches!(utility, UtilityView::Mutation(form) if form.running) || matches!(utility, UtilityView::Git(form) if form.running) + || matches!(utility, UtilityView::Otp(form) if form.running) }) { self.status = "Wait for the active workflow to finish…".to_owned(); } else { + if matches!(&self.utility, Some(UtilityView::Otp(_))) { + self.sensitive.otp = None; + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + self.otp_generation = self.otp_generation.wrapping_add(1); + self.otp_pending = false; + } self.utility = None; } } @@ -1520,6 +1638,217 @@ impl App { } } } + Message::OtpEntryChanged(entry) => { + if let Some(UtilityView::Otp(form)) = &mut self.utility + && !form.running + { + form.entry = entry; + form.error = None; + } + } + Message::OtpUriChanged(uri) => { + if let Some(UtilityView::Otp(form)) = &mut self.utility + && !form.running + { + form.uri = uri; + form.error = None; + } + } + Message::ToggleOtpReplace => { + if let Some(UtilityView::Otp(form)) = &mut self.utility + && !form.running + { + form.replace = !form.replace; + form.error = None; + } + } + Message::ToggleOtpRemovalConfirmation => { + if let Some(UtilityView::Otp(form)) = &mut self.utility + && !form.running + { + form.remove_confirmed = !form.remove_confirmed; + form.error = None; + } + } + Message::PickOtpQr => { + if matches!(&self.utility, Some(UtilityView::Otp(form)) if !form.running) { + return Task::perform( + async { Arc::new(Mutex::new(Some(folder_picker::pick_qr_image().await))) }, + Message::OtpQrPicked, + ); + } + } + Message::OtpQrPicked(completion) => match take_completion(&completion) { + None => return Task::none(), + Some(Ok(Some(image))) => return self.begin_otp_import(Some(image)), + Some(Ok(None)) => { + self.status = "OTP QR import cancelled; store unchanged.".to_owned() + } + Some(Err(error)) => { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some(error.clone()); + } + self.status = format!("OTP QR import failed: {error}. Store unchanged."); + } + }, + Message::SubmitOtpImport => return self.begin_otp_import(None), + Message::SubmitOtpRemoval => { + let Some(UtilityView::Otp(form)) = &self.utility else { + return Task::none(); + }; + if !form.remove_confirmed { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some("Confirm permanent OTP removal first.".to_owned()); + } + return Task::none(); + } + return self.begin_otp_removal(); + } + Message::RunOtpCode(copy) => { + let hotp = self + .editor + .as_ref() + .and_then(|editor| editor.focused().and_then(|id| editor.document().field(id))) + .and_then(|field| field.metadata().otp()) + .is_some_and(|otp| otp.kind() == OtpKind::Hotp); + if hotp { + if let Some(UtilityView::Otp(form)) = &mut self.utility + && !form.running + { + form.copy_after_code = copy; + form.hotp_confirmation = true; + form.error = None; + } + self.status = + "Confirm HOTP generation; storage will commit the advanced counter." + .to_owned(); + } else { + return self.begin_otp_code(copy, false); + } + } + Message::RunOtpUri { qr, copy } => return self.begin_otp_uri(qr, copy), + Message::ConfirmHotp => { + let copy = match &mut self.utility { + Some(UtilityView::Otp(form)) if form.hotp_confirmation && !form.running => { + form.hotp_confirmation = false; + form.copy_after_code + } + _ => return Task::none(), + }; + return self.begin_otp_code(copy, true); + } + Message::OtpFinished { + generation, + completion, + } => { + let Some(result) = take_completion(&completion) else { + return Task::none(); + }; + if generation != self.otp_generation { + return Task::none(); + } + self.otp_pending = false; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.running = false; + } + match result { + Ok(OtpTaskResult::Code { + entry, + copy, + observed_at, + outcome, + }) => { + let (code, validity, metadata, tree, document) = outcome.into_parts(); + if let Some(tree) = tree { + self.navigation.replace(&tree); + self.tree_state = + tree_state_from_result(Ok(self.navigation.is_empty())); + } + if let Some(document) = document { + let mut editor = EntryEditor::new(document); + if let Some(id) = editor + .fields() + .iter() + .find(|field| field.metadata().kind() == EntryFieldKind::OtpUri) + .map(|field| field.id()) + { + editor.select(id); + } + self.editor = Some(editor); + self.content_mode = ContentMode::Viewer; + } + let copy_value = copy.then(|| SecretBytes::new(code.expose().to_vec())); + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + self.sensitive.otp = Some(OtpDisplay { + entry, + code, + validity, + metadata, + observed_at, + }); + self.status = validity.counter().map_or_else( + || "TOTP code generated from storage metadata.".to_owned(), + |counter| format!("HOTP counter {counter} generated and committed."), + ); + if let Some(value) = copy_value { + return self.begin_secret_copy(value); + } + } + Ok(OtpTaskResult::Uri { + entry, + copy, + outcome, + }) => { + let (payload, matrix) = outcome.into_parts(); + self.sensitive.otp = None; + if copy { + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + self.status = format!("Copying OTP URI for {entry}."); + return self.begin_secret_copy(payload); + } + self.sensitive.otp_uri = matrix + .is_none() + .then(|| SecretBytes::new(payload.expose().to_vec())); + self.sensitive.otp_qr = matrix; + self.status = format!("Presented OTP URI for {entry}."); + } + Ok(OtpTaskResult::Imported(outcome)) | Ok(OtpTaskResult::Removed(outcome)) => { + let (entry, document, tree) = outcome.into_parts(); + self.navigation.replace(&tree); + self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty())); + let mut editor = EntryEditor::new(document); + if let Some(id) = editor + .fields() + .iter() + .find(|field| field.metadata().kind() == EntryFieldKind::OtpUri) + .map(|field| field.id()) + { + editor.select(id); + } + self.editor = Some(editor); + self.entry_path = entry; + self.content_mode = ContentMode::Viewer; + self.sensitive.otp = None; + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.uri.clear(); + form.replace = false; + form.remove_confirmed = false; + form.error = None; + } + self.status = "OTP mutation committed and entry reloaded.".to_owned(); + } + Err(error) => { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some(error.to_string()); + } + self.status = format!("OTP operation failed: {error}. Store unchanged."); + } + } + } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { @@ -1788,9 +2117,12 @@ impl App { if !authentication_allows_content(&self.authentication) { return Task::none(); } - let (Some(storage), Some(editor)) = (&self.storage, &self.editor) else { + let Some(editor) = &self.editor else { return Task::none(); }; + if self.storage.is_none() { + return Task::none(); + } let value = match editor.copy_value(id) { Ok(value) => value, Err(error) => { @@ -1798,12 +2130,7 @@ impl App { 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, storage.clipboard_timeout(), cancel), - move |result| Message::CopyFinished { generation, result }, - ); + return self.begin_secret_copy(value); } Message::CopyFinished { generation, result } => { if self.sensitive.finish_copy(generation) { @@ -1884,6 +2211,28 @@ impl App { Err(error) => self.authentication_lost(error.to_string()), } } + if let Some(remaining) = self.sensitive.clipboard_remaining(Instant::now()) { + self.status = format!("Clipboard cleanup in {remaining}s."); + } + let unix_seconds = current_unix_seconds().ok(); + let refresh_totp = unix_seconds.and_then(|now| { + self.sensitive.otp.as_mut().and_then(|display| { + display.observed_at = now; + (display.validity.counter().is_none() + && display.remaining_at(now) == Some(0)) + .then(|| display.entry.clone()) + }) + }); + if refresh_totp.is_some_and(|entry| { + matches!( + &self.utility, + Some(UtilityView::Otp(form)) if form.entry.trim() == entry + ) + }) && !self.otp_pending + && self.handle.is_some() + { + return self.begin_otp_code(false, false); + } } Message::Lock => { self.authentication_generation = self.authentication_generation.wrapping_add(1); @@ -1925,6 +2274,7 @@ impl App { field.metadata().sensitivity() == EntrySensitivity::Sensitive && field.metadata().kind() != EntryFieldKind::OtpUri }), + focused_otp: focused.is_some_and(|field| field.metadata().otp().is_some()), entry_path: !self.entry_path.trim().is_empty(), selected_object: self.navigation.selected().is_some(), git_running: self.git_control.is_some(), @@ -2039,6 +2389,59 @@ impl App { return self.update(Message::ToggleReveal(id)); } } + UiAction::ImportOtp => { + self.sensitive.otp = None; + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + self.utility = Some(UtilityView::Otp(OtpForm::new( + self.entry_path.trim().to_owned(), + ))); + } + UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp => { + let Some(editor) = &self.editor else { + return Task::none(); + }; + let Some(otp_kind) = editor + .focused() + .and_then(|id| editor.document().field(id)) + .and_then(|field| field.metadata().otp()) + .map(|otp| otp.kind()) + else { + return Task::none(); + }; + let entry = editor.entry(); + self.sensitive.otp = None; + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; + self.utility = Some(UtilityView::Otp(OtpForm::new(entry))); + match action { + UiAction::GenerateOtp | UiAction::CopyOtp if otp_kind == OtpKind::Hotp => { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.copy_after_code = action == UiAction::CopyOtp; + form.hotp_confirmation = true; + } + self.status = + "Confirm HOTP generation; storage will commit the advanced counter." + .to_owned(); + } + UiAction::GenerateOtp | UiAction::CopyOtp => { + return self.begin_otp_code(action == UiAction::CopyOtp, false); + } + UiAction::ShowOtpUri => return self.begin_otp_uri(false, false), + UiAction::CopyOtpUri => return self.begin_otp_uri(false, true), + UiAction::ShowOtpQr => return self.begin_otp_uri(true, false), + UiAction::RemoveOtp => { + self.status = + "Confirm OTP removal; the entry change will be committed.".to_owned(); + } + _ => unreachable!(), + } + } UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => { let request = match action { UiAction::GitStatus => DesktopGitRequest::Refresh, @@ -2344,6 +2747,187 @@ impl App { ) } + fn begin_otp_code(&mut self, copy: bool, confirm_hotp: bool) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let entry = match &self.utility { + Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(), + _ => return Task::none(), + }; + let unix_seconds = match current_unix_seconds() { + Ok(unix_seconds) => unix_seconds, + Err(error) => { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some(error.clone()); + } + self.status = error; + return Task::none(); + } + }; + self.otp_generation = self.otp_generation.wrapping_add(1); + let generation = self.otp_generation; + self.otp_pending = true; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + self.status = format!("Generating OTP code for {entry}…"); + Task::perform( + async move { + Arc::new(Mutex::new(Some( + storage + .otp_code_active(&handle, &entry, unix_seconds, confirm_hotp) + .map(|outcome| OtpTaskResult::Code { + entry, + copy, + observed_at: unix_seconds, + outcome, + }), + ))) + }, + move |completion| Message::OtpFinished { + generation, + completion, + }, + ) + } + + fn begin_otp_uri(&mut self, qr: bool, copy: bool) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let entry = match &self.utility { + Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(), + _ => return Task::none(), + }; + self.otp_generation = self.otp_generation.wrapping_add(1); + let generation = self.otp_generation; + self.otp_pending = true; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + Task::perform( + async move { + Arc::new(Mutex::new(Some( + storage + .otp_uri_active(&handle, &entry, qr) + .map(|outcome| OtpTaskResult::Uri { + entry, + copy, + outcome, + }), + ))) + }, + move |completion| Message::OtpFinished { + generation, + completion, + }, + ) + } + + fn begin_otp_import(&mut self, image: Option) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let (entry, uri, replace) = match &self.utility { + Some(UtilityView::Otp(form)) if !form.running => ( + form.entry.trim().to_owned(), + SecretBytes::new(form.uri.as_bytes().to_vec()), + form.replace, + ), + _ => return Task::none(), + }; + if entry.is_empty() { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some("Enter an entry path.".to_owned()); + } + return Task::none(); + } + if image.is_none() && uri.expose().is_empty() { + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.error = Some("Enter an otpauth URI or choose a QR image.".to_owned()); + } + return Task::none(); + } + let replace = if replace { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }; + self.otp_generation = self.otp_generation.wrapping_add(1); + let generation = self.otp_generation; + self.otp_pending = true; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + self.status = format!("Importing OTP into {entry}…"); + Task::perform( + async move { + Arc::new(Mutex::new(Some( + match image { + Some(image) => { + storage.import_otp_qr_active(&handle, &entry, image, replace) + } + None => storage.import_otp_active(&handle, &entry, uri, replace), + } + .map(OtpTaskResult::Imported), + ))) + }, + move |completion| Message::OtpFinished { + generation, + completion, + }, + ) + } + + fn begin_otp_removal(&mut self) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let entry = match &self.utility { + Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(), + _ => return Task::none(), + }; + self.otp_generation = self.otp_generation.wrapping_add(1); + let generation = self.otp_generation; + self.otp_pending = true; + if let Some(UtilityView::Otp(form)) = &mut self.utility { + form.running = true; + form.error = None; + } + Task::perform( + async move { + Arc::new(Mutex::new(Some( + storage + .remove_otp_active(&handle, &entry) + .map(OtpTaskResult::Removed), + ))) + }, + move |completion| Message::OtpFinished { + generation, + completion, + }, + ) + } + + fn begin_secret_copy(&mut self, value: SecretBytes) -> Task { + let Some(storage) = &self.storage else { + return Task::none(); + }; + let timeout = storage.clipboard_timeout(); + let (generation, cancel) = self.sensitive.begin_copy(timeout.duration()); + self.status = format!( + "Copied; automatic clipboard cleanup in {}s.", + timeout.duration().as_secs() + ); + Task::perform(copy_to_clipboard(value, timeout, cancel), move |result| { + Message::CopyFinished { generation, result } + }) + } + fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { return Task::none(); @@ -2699,6 +3283,12 @@ impl App { form.running = false; form.error = Some(reason.clone()); } + Some(UtilityView::Otp(form)) => { + form.running = false; + form.hotp_confirmation = false; + form.uri.clear(); + form.error = Some(reason.clone()); + } _ => {} } self.authentication = AuthenticationView::Locked; @@ -3368,6 +3958,88 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } } + UtilityView::Otp(form) => { + content = content + .push(text("One-Time Password").size(28)) + .push(text( + "Code generation, counters, URI validation, QR payloads, entry mutation, Git commits, and clipboard policy are owned by crates/storage.", + )) + .push(text("Entry path")) + .push( + text_input("folder/entry", &form.entry) + .on_input(Message::OtpEntryChanged), + ); + if let Some(display) = &app.sensitive.otp { + content = content + .push( + text(format!( + "{} code for {}", + match display.metadata.kind() { + OtpKind::Totp => "TOTP", + OtpKind::Hotp => "HOTP", + }, + display.entry, + )) + .size(20), + ) + .push( + text(String::from_utf8_lossy(display.code.expose()).into_owned()).size(36), + ) + .push(text(format!( + "{} · {} · {:?} · {} digits", + display.metadata.issuer().unwrap_or("unknown issuer"), + display.metadata.account(), + display.metadata.algorithm(), + display.metadata.digits(), + ))); + if let Some(remaining) = display.remaining_at(display.observed_at) { + content = content.push(text(format!("Valid for {remaining}s"))); + } + if let Some(counter) = display.validity.counter() { + content = content.push(text(format!("Committed HOTP counter {counter}"))); + } + } + if let Some(uri) = &app.sensitive.otp_uri { + content = content + .push(text("OTP URI (explicitly revealed)").size(20)) + .push(text(String::from_utf8_lossy(uri.expose()).into_owned())); + } + if let Some(matrix) = &app.sensitive.otp_qr { + content = content + .push(text("OTP QR (explicitly revealed)").size(20)) + .push( + canvas(QrCanvas(matrix)) + .width(Length::Fixed(320.0)) + .height(Length::Fixed(320.0)), + ); + } + content = content + .push(text("Import or replace OTP").size(20)) + .push( + text_input("otpauth://…", &form.uri) + .on_input(|value| Message::OtpUriChanged(Zeroizing::new(value))), + ) + .push(search_option( + "Replace an existing OTP URI", + form.replace, + Message::ToggleOtpReplace, + form.running, + )); + if form.hotp_confirmation { + content = content.push(text( + "HOTP generation advances and commits the counter. This cannot be treated as a read-only refresh.", + )); + } + content = content.push(search_option( + "Permanently remove the OTP URI and commit the entry", + form.remove_confirmed, + Message::ToggleOtpRemovalConfirmation, + form.running, + )); + if let Some(error) = &form.error { + content = content.push(text(format!("OTP error: {error}"))); + } + } UtilityView::Help => { content = content .push(text("IronStorage Help").size(28)) @@ -3410,7 +4082,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa || matches!(utility, UtilityView::NewEntry(form) if form.running) || matches!(utility, UtilityView::Search(form) if form.running) || matches!(utility, UtilityView::Mutation(form) if form.running) - || matches!(utility, UtilityView::Git(form) if form.running); + || matches!(utility, UtilityView::Git(form) if form.running) + || matches!(utility, UtilityView::Otp(form) if form.running); let done = if busy { done } else { @@ -3524,6 +4197,39 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa actions.push(done) } } + UtilityView::Otp(form) => { + if form.running { + row![button("Working…"), done].spacing(8) + } else { + let mut actions = row![ + button("Generate code").on_press(Message::RunOtpCode(false)), + button("Copy code").on_press(Message::RunOtpCode(true)), + button("Show URI").on_press(Message::RunOtpUri { + qr: false, + copy: false, + }), + button("Copy URI").on_press(Message::RunOtpUri { + qr: false, + copy: true, + }), + button("Show QR").on_press(Message::RunOtpUri { + qr: true, + copy: false, + }), + ] + .spacing(8); + if form.hotp_confirmation { + actions = actions.push( + button("Confirm HOTP counter advance").on_press(Message::ConfirmHotp), + ); + } + actions + .push(button("Import URI").on_press(Message::SubmitOtpImport)) + .push(button("Import QR image…").on_press(Message::PickOtpQr)) + .push(button("Remove OTP").on_press(Message::SubmitOtpRemoval)) + .push(done) + } + } UtilityView::About | UtilityView::Help => row![done], }; container( @@ -3536,12 +4242,12 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .into() } -fn search_option( - label: &'static str, +fn search_option<'a>( + label: &'a str, selected: bool, message: Message, running: bool, -) -> iced::widget::Button<'static, Message> { +) -> iced::widget::Button<'a, Message> { let option = button(text(format!( "[{}] {label}", if selected { "x" } else { " " } @@ -3553,6 +4259,45 @@ fn search_option( } } +struct QrCanvas<'a>(&'a QrMatrix); + +impl canvas::Program for QrCanvas<'_> { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: iced::mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + frame.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + let padded = self.0.width() + 8; + let scale = (bounds.width.min(bounds.height) / padded as f32).floor(); + let offset = Point::new( + (bounds.width - scale * padded as f32) / 2.0, + (bounds.height - scale * padded as f32) / 2.0, + ); + for y in 0..self.0.width() { + for x in 0..self.0.width() { + if self.0.is_dark(x, y) == Some(true) { + frame.fill_rectangle( + Point::new( + offset.x + (x + 4) as f32 * scale, + offset.y + (y + 4) as f32 * scale, + ), + iced::Size::new(scale, scale), + Color::BLACK, + ); + } + } + } + vec![frame.into_geometry()] + } +} + fn sidebar_view<'a>( navigation: &'a NavigationTree, state: &'a TreeState, @@ -3826,6 +4571,19 @@ fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> { otp.algorithm(), otp.digits(), ))); + field_view = field_view.push( + row![ + button("Generate code") + .on_press(Message::FieldAction(id, UiAction::GenerateOtp)), + button("Copy code").on_press(Message::FieldAction(id, UiAction::CopyOtp)), + button("Show URI").on_press(Message::FieldAction(id, UiAction::ShowOtpUri)), + button("Copy URI").on_press(Message::FieldAction(id, UiAction::CopyOtpUri)), + button("Show QR").on_press(Message::FieldAction(id, UiAction::ShowOtpQr)), + button("Remove OTP").on_press(Message::FieldAction(id, UiAction::RemoveOtp)), + ] + .spacing(6) + .wrap(), + ); } if let Some(diagnostic) = field.metadata().diagnostic() { field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12)); @@ -4189,6 +4947,13 @@ async fn copy_to_clipboard( Ok(format!("Clipboard cleanup complete: {disposition:?}")) } +fn current_unix_seconds() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "the system clock is before the Unix epoch".to_owned()) +} + fn poll_lease( session: &AuthenticationSession, handle: &mut Option>, @@ -4438,6 +5203,8 @@ mod tests { vault_generation: 0, settings_generation: 0, workflow_generation: 0, + otp_generation: 0, + otp_pending: false, selection_after_refresh: None, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, @@ -4872,7 +5639,7 @@ mod tests { assert!(app.editor.is_none()); assert!(app.sensitive.clipboard_cancel.is_none()); - let (generation, cancel) = app.sensitive.begin_copy(); + let (generation, cancel) = app.sensitive.begin_copy(Duration::from_secs(45)); app.sensitive.clear(); assert!(cancel.load(Ordering::Acquire)); assert!(!app.sensitive.finish_copy(generation)); @@ -4883,6 +5650,7 @@ mod tests { let mut sensitive = SensitiveUiState { clipboard_cancel: Some(Arc::new(AtomicBool::new(false))), clipboard_generation: 0, + ..SensitiveUiState::default() }; clock.advance(timeout); assert_eq!( @@ -4893,6 +5661,131 @@ mod tests { assert!(sensitive.clipboard_cancel.is_none()); } + #[test] + fn storage_rfc_totp_countdown_does_not_renew_authentication_and_lock_clears_presentations() { + let (_temporary, storage) = fixture_storage(); + let mut provider = FixtureSecrets; + let mut document = storage + .create_document("otp/rfc6238", &mut provider) + .expect("create OTP document"); + document + .add( + 0, + ironstorage::document::EntryFieldDraft::line(b"password".to_vec()) + .expect("password"), + ) + .expect("password field"); + document + .add( + 1, + ironstorage::document::EntryFieldDraft::otp_uri( + b"otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30".to_vec(), + ) + .expect("RFC URI"), + ) + .expect("OTP field"); + storage.save_document(&document).expect("save OTP fixture"); + let outcome = storage + .otp_code("otp/rfc6238", 59, false, &mut provider) + .expect("desktop OTP outcome"); + let (code, validity, metadata, _, _) = outcome.into_parts(); + assert_eq!(code.expose(), b"94287082"); + let display = OtpDisplay { + entry: "otp/rfc6238".to_owned(), + code, + validity, + metadata, + observed_at: 59, + }; + assert_eq!(display.remaining_at(59), Some(1)); + assert_eq!(display.remaining_at(60), Some(0)); + + let mut hotp_document = storage + .create_document("otp/rfc4226", &mut provider) + .expect("create HOTP document"); + hotp_document + .add( + 0, + ironstorage::document::EntryFieldDraft::line(b"password".to_vec()) + .expect("password"), + ) + .expect("password field"); + hotp_document + .add( + 1, + ironstorage::document::EntryFieldDraft::otp_uri( + b"otpauth://hotp/RFC4226?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0" + .to_vec(), + ) + .expect("RFC HOTP URI"), + ) + .expect("HOTP field"); + storage + .save_document(&hotp_document) + .expect("save HOTP fixture"); + let hotp = storage + .otp_code("otp/rfc4226", 59, true, &mut provider) + .expect("HOTP outcome"); + let mut app = test_app(None); + app.otp_generation = 1; + app.utility = Some(UtilityView::Otp(OtpForm::new("otp/rfc4226".to_owned()))); + let _task = app.update(Message::OtpFinished { + generation: 1, + completion: Arc::new(Mutex::new(Some(Ok(OtpTaskResult::Code { + entry: "otp/rfc4226".to_owned(), + copy: false, + observed_at: 59, + outcome: hotp, + })))), + }); + assert_eq!( + app.sensitive + .otp + .as_ref() + .expect("HOTP displayed") + .code + .expose(), + b"287082" + ); + assert!(app.status.contains("counter 1")); + let refreshed = app.editor.as_ref().expect("refreshed HOTP entry"); + assert_eq!(refreshed.entry(), "otp/rfc4226"); + let refreshed_otp = refreshed + .fields() + .iter() + .find_map(|field| field.metadata().otp()) + .expect("refreshed HOTP metadata"); + assert_eq!(refreshed_otp.kind(), OtpKind::Hotp); + assert_eq!(refreshed_otp.counter(), Some(1)); + + let timeout = Duration::from_secs(2); + let (session, key, clock) = session(timeout); + let mut handle = Some(session.authenticate(&key).expect("authenticate")); + let mut sensitive = SensitiveUiState { + otp: Some(display), + otp_uri: Some(SecretBytes::new(b"otpauth://secret".to_vec())), + otp_qr: Some( + QrMatrix::encode(&SecretBytes::new(b"otpauth://secret".to_vec())).expect("QR"), + ), + ..SensitiveUiState::default() + }; + clock.advance(Duration::from_secs(1)); + assert_eq!( + poll_lease(&session, &mut handle, &mut sensitive).expect("countdown poll"), + LeasePoll::Active(Duration::from_secs(1)) + ); + sensitive.otp.as_mut().expect("OTP display").observed_at = 60; + clock.advance(Duration::from_secs(1)); + assert_eq!( + poll_lease(&session, &mut handle, &mut sensitive).expect("expiry poll"), + LeasePoll::Expired + ); + assert!(handle.is_none()); + assert!(sensitive.otp.is_none()); + assert!(sensitive.otp_uri.is_none()); + assert!(sensitive.otp_qr.is_none()); + } + #[test] fn deliberate_input_and_primary_save_are_distinct_from_passive_events() { assert!(!is_deliberate_activity(&Event::Window( diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 59d4958..f792ab0 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -145,6 +145,13 @@ fn accelerator(action: UiAction) -> Option { | UiAction::CopyEntry | UiAction::DeleteEntry | UiAction::ToggleReveal + | UiAction::GenerateOtp + | UiAction::CopyOtp + | UiAction::ImportOtp + | UiAction::ShowOtpUri + | UiAction::CopyOtpUri + | UiAction::ShowOtpQr + | UiAction::RemoveOtp | UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index d6b1e30..4e6face 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -16,12 +16,14 @@ flate2.workspace = true gix.workspace = true gix-config.workspace = true hmac.workspace = true +image.workspace = true keyring-core.workspace = true pgp.workspace = true qrcode.workspace = true rand.workspace = true regex.workspace = true reqwest.workspace = true +rqrr.workspace = true serde.workspace = true sha1.workspace = true sha2.workspace = true @@ -49,6 +51,5 @@ arboard.workspace = true hex = "0.4" nix = { version = "0.31", features = ["fs"] } rand_chacha = "0.3" -rqrr.workspace = true smallvec = "1.15" tempfile = "3" diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index 1ade0ef..db7769b 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -6,10 +6,16 @@ use crate::{ authentication::{ AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession, }, - command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, + command::{ + CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, OtpInputSource, + OtpInsertRequest, RemoveRequest, + }, config::{Config, ConfigSettings, EditorCommand}, crypto::{KeyInfo, KeyStore, SecretProvider}, - document::{DocumentError, EntryDocument, EntryDocumentService}, + document::{ + DocumentError, EntryDocument, EntryDocumentService, EntryFieldDraft, EntryFieldId, + EntryFieldKind, + }, git::{ AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity, @@ -17,13 +23,14 @@ use crate::{ PushOutcome, ReqwestGitTransport, }, mutation::{MutationOutcome, TreeMutator}, - presentation::ClipboardTimeout, + otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri}, + presentation::{ClipboardTimeout, QrMatrix}, read::{FindResults, GrepResults, TreeModel, VaultReader}, recipient::{ PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager, RecipientPolicyOutcome, }, - repository::{DirectoryPath, Repository}, + repository::{DirectoryPath, Repository, SecretBytes}, secret_store::SecretProtectionPolicy, write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome}, }; @@ -42,6 +49,7 @@ pub enum DesktopErrorKind { MissingDefaultKey, EntryExists, Mutation, + Otp, } #[derive(Debug)] @@ -159,6 +167,112 @@ pub struct DesktopGitResult { tree: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopOtpMetadata { + kind: OtpKind, + issuer: Option, + account: String, + algorithm: OtpAlgorithm, + digits: u32, +} + +impl DesktopOtpMetadata { + pub fn kind(&self) -> OtpKind { + self.kind + } + + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn algorithm(&self) -> OtpAlgorithm { + self.algorithm + } + + pub fn digits(&self) -> u32 { + self.digits + } +} + +pub struct DesktopOtpCode { + code: SecretBytes, + validity: OtpCodeValidity, + metadata: DesktopOtpMetadata, + tree: Option, + document: Option, +} + +impl DesktopOtpCode { + pub fn code(&self) -> &SecretBytes { + &self.code + } + + pub fn validity(&self) -> OtpCodeValidity { + self.validity + } + + pub fn metadata(&self) -> &DesktopOtpMetadata { + &self.metadata + } + + pub fn into_parts( + self, + ) -> ( + SecretBytes, + OtpCodeValidity, + DesktopOtpMetadata, + Option, + Option, + ) { + ( + self.code, + self.validity, + self.metadata, + self.tree, + self.document, + ) + } +} + +pub struct DesktopOtpUri { + payload: SecretBytes, + matrix: Option, +} + +impl DesktopOtpUri { + pub fn payload(&self) -> &SecretBytes { + &self.payload + } + + pub fn matrix(&self) -> Option<&QrMatrix> { + self.matrix.as_ref() + } + + pub fn into_parts(self) -> (SecretBytes, Option) { + (self.payload, self.matrix) + } +} + +pub struct DesktopOtpMutation { + entry: String, + document: EntryDocument, + tree: TreeModel, +} + +impl DesktopOtpMutation { + pub fn entry(&self) -> &str { + &self.entry + } + + pub fn into_parts(self) -> (String, EntryDocument, TreeModel) { + (self.entry, self.document, self.tree) + } +} + impl DesktopGitResult { pub fn outcome(&self) -> &DesktopGitOutcome { &self.outcome @@ -420,6 +534,263 @@ impl DesktopStorage { .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } + pub fn otp_code_active( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + unix_seconds: u64, + confirm_hotp: bool, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.otp_code(entry, unix_seconds, confirm_hotp, &mut provider) + } + + pub fn otp_code( + &self, + entry: &str, + unix_seconds: u64, + confirm_hotp: bool, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + let service = OtpService::new(&repository, &keys); + let uri = service + .uri(entry, provider) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + if uri.kind() == OtpKind::Hotp && !confirm_hotp { + return Err(DesktopError::new( + DesktopErrorKind::Otp, + "HOTP generation requires explicit confirmation because it commits the advanced counter", + )); + } + let metadata = DesktopOtpMetadata { + kind: uri.kind(), + issuer: uri.issuer().map(str::to_owned), + account: uri.account().to_owned(), + algorithm: uri.algorithm(), + digits: uri.digits(), + }; + let outcome = service + .code_automatic(entry, unix_seconds, None, provider) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let validity = outcome.validity(); + let changed = validity.counter().is_some(); + let tree = changed.then(|| self.tree()).transpose()?; + let document = changed + .then(|| { + EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document) + }) + .transpose()?; + Ok(DesktopOtpCode { + code: SecretBytes::new(outcome.code().expose().to_vec()), + validity, + metadata, + tree, + document, + }) + } + + pub fn otp_uri_active( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + qr: bool, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.otp_uri(entry, qr, &mut provider) + } + + pub fn otp_uri( + &self, + entry: &str, + qr: bool, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + let uri = OtpService::new(&repository, &keys) + .uri(entry, provider) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let payload = SecretBytes::new(uri.encoded().expose().to_vec()); + let matrix = qr + .then(|| QrMatrix::encode(&payload)) + .transpose() + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + Ok(DesktopOtpUri { payload, matrix }) + } + + pub fn import_otp_active( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + uri: SecretBytes, + replace: OverwriteDecision, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.import_otp(entry, uri, replace, &mut provider) + } + + pub fn import_otp_qr_active( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + image: SecretBytes, + replace: OverwriteDecision, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.import_otp_qr(entry, image, replace, &mut provider) + } + + pub fn import_otp_qr( + &self, + entry: &str, + image: SecretBytes, + replace: OverwriteDecision, + provider: &mut impl SecretProvider, + ) -> Result { + let uri = QrMatrix::decode_image(&image) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + self.import_otp(entry, uri, replace, provider) + } + + pub fn import_otp( + &self, + entry: &str, + uri: SecretBytes, + replace: OverwriteDecision, + provider: &mut impl SecretProvider, + ) -> Result { + let parsed = + OtpUri::parse(uri).map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let encoded = parsed.encoded().expose().to_vec(); + let repository = self.repository()?; + let keys = self.keys()?; + let exists = VaultWriter::new(&repository, &keys) + .entry_exists(entry) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + if exists { + let mut document = EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document)?; + if let Some(field) = otp_field(&document)? { + if replace == OverwriteDecision::Decline { + return Err(DesktopError::new( + DesktopErrorKind::EntryExists, + format!("entry already contains an OTP URI: {entry}"), + )); + } + document + .replace_field_value(field, encoded.clone()) + .map_err(DesktopError::document)?; + } else { + let index = document.fields().len(); + document + .add( + index, + EntryFieldDraft::otp_uri(encoded.clone()) + .map_err(DesktopError::document)?, + ) + .map_err(DesktopError::document)?; + } + self.save_document(&document)?; + let document = EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document)?; + return Ok(DesktopOtpMutation { + entry: entry.to_owned(), + document, + tree: self.tree()?, + }); + } + let request = OtpInsertRequest { + entry: Some(entry.to_owned()), + force: false, + echo: true, + source: OtpInputSource::Uri, + }; + let input = OtpInput::line(encoded) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let service = OtpService::new(&repository, &keys); + let plan = service + .prepare_insert(&request, input) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let mut committer = + AutomaticEntryCommitter::for_entry(&repository, entry, GitIdentity::ironstorage()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; + service + .finish_insert( + plan, + OverwriteDecision::Allow, + OverwriteDecision::Decline, + None, + &mut committer, + ) + .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; + let document = EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document)?; + Ok(DesktopOtpMutation { + entry: entry.to_owned(), + document, + tree: self.tree()?, + }) + } + + pub fn remove_otp_active( + &self, + handle: &NativeAuthenticationHandle, + entry: &str, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.remove_otp(entry, &mut provider) + } + + pub fn remove_otp( + &self, + entry: &str, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + let mut document = EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document)?; + let field = otp_field(&document)?.ok_or_else(|| { + DesktopError::new( + DesktopErrorKind::Otp, + format!("entry does not contain an OTP URI: {entry}"), + ) + })?; + document.remove(field).map_err(DesktopError::document)?; + self.save_document(&document)?; + let document = EntryDocumentService::new(&repository, &keys) + .open(entry, provider) + .map_err(DesktopError::document)?; + Ok(DesktopOtpMutation { + entry: entry.to_owned(), + document, + tree: self.tree()?, + }) + } + pub fn mutate_active( &self, handle: &NativeAuthenticationHandle, @@ -611,6 +982,22 @@ impl DesktopStorage { } } +fn otp_field(document: &EntryDocument) -> Result, DesktopError> { + let mut fields = document + .fields() + .iter() + .filter(|field| field.metadata().kind() == EntryFieldKind::OtpUri) + .map(|field| field.id()); + let first = fields.next(); + if fields.next().is_some() { + return Err(DesktopError::new( + DesktopErrorKind::Otp, + format!("entry contains multiple OTP URIs: {}", document.path()), + )); + } + Ok(first) +} + struct ConfigPolicyCommitter<'a> { git: AutomaticPolicyCommitter, previous: &'a Config, diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index a3c7cde..91bbb94 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -550,20 +550,6 @@ fn classify_all(fields: &mut [EntryField]) { } fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { - if index == 0 { - return EntryFieldMetadata { - kind: EntryFieldKind::Password, - sensitivity: EntrySensitivity::Sensitive, - name: Some("password".to_owned()), - otp: None, - diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err()) - .then_some(EntryFieldDiagnostic::NonUtf8Value), - value: 0..line.len(), - }; - } - if line.is_empty() { - return blank_metadata(); - } if line.starts_with(b"otpauth://") && let Ok(uri) = OtpUri::parse(SecretBytes::new(line.to_vec())) { @@ -585,6 +571,20 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { value: 0..line.len(), }; } + if index == 0 { + return EntryFieldMetadata { + kind: EntryFieldKind::Password, + sensitivity: EntrySensitivity::Sensitive, + name: Some("password".to_owned()), + otp: None, + diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err()) + .then_some(EntryFieldDiagnostic::NonUtf8Value), + value: 0..line.len(), + }; + } + if line.is_empty() { + return blank_metadata(); + } if line.starts_with(b"otpauth://") { return EntryFieldMetadata { kind: EntryFieldKind::OtpUri, diff --git a/crates/storage/src/presentation.rs b/crates/storage/src/presentation.rs index a3a72b8..7646f8c 100644 --- a/crates/storage/src/presentation.rs +++ b/crates/storage/src/presentation.rs @@ -198,6 +198,9 @@ impl NativeClipboardManager { pub enum QrError { EmptyPayload, PayloadTooLarge, + InvalidImage, + NotFound, + InvalidPayload, } impl fmt::Display for QrError { @@ -205,6 +208,9 @@ impl fmt::Display for QrError { match self { Self::EmptyPayload => formatter.write_str("empty data cannot be encoded as a QR code"), Self::PayloadTooLarge => formatter.write_str("the QR payload is too large"), + Self::InvalidImage => formatter.write_str("the selected file is not a supported image"), + Self::NotFound => formatter.write_str("the image does not contain a QR code"), + Self::InvalidPayload => formatter.write_str("the QR code contains invalid text"), } } } @@ -233,6 +239,26 @@ impl QrMatrix { Ok(Self { width, modules }) } + /// Decode the first QR symbol from an encoded image without exposing the + /// secret-derived payload to a presentation adapter. + pub fn decode_image(image: &SecretBytes) -> Result { + let grayscale = image::load_from_memory(image.expose()) + .map_err(|_| QrError::InvalidImage)? + .into_luma8(); + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( + grayscale.width() as usize, + grayscale.height() as usize, + |x, y| grayscale.get_pixel(x as u32, y as u32).0[0], + ); + let grid = prepared + .detect_grids() + .into_iter() + .next() + .ok_or(QrError::NotFound)?; + let (_, payload) = grid.decode().map_err(|_| QrError::InvalidPayload)?; + Ok(SecretBytes::new(payload.into_bytes())) + } + pub fn width(&self) -> usize { self.width } diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index 3b43070..e7d60a1 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -148,6 +148,36 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { Ok(()) } +#[test] +fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + write_plaintext( + &repository, + &keys, + "documents/otp-only", + b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n", + )?; + let document = + EntryDocumentService::new(&repository, &keys).open("documents/otp-only", &mut secrets)?; + assert_eq!(document.fields().len(), 1); + assert_eq!( + document.fields()[0].metadata().kind(), + EntryFieldKind::OtpUri + ); + assert_eq!( + document.fields()[0] + .metadata() + .otp() + .and_then(|otp| otp.issuer()), + Some("Example") + ); + Ok(()) +} + #[test] fn field_ids_survive_updates_removal_and_reordering() -> TestResult { let fixture = FixtureSet::load()?; diff --git a/crates/storage/tests/otp.rs b/crates/storage/tests/otp.rs index 5ee8efd..87a1a27 100644 --- a/crates/storage/tests/otp.rs +++ b/crates/storage/tests/otp.rs @@ -8,6 +8,7 @@ use data_encoding::BASE32_NOPAD; use ironstorage::{ command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + desktop::{DesktopErrorKind, DesktopStorage}, git::{GitIdentity, GitRepository}, otp::{OtpAlgorithm, OtpCodeValidity, OtpError, OtpInput, OtpKind, OtpService, OtpUri}, recipient::RecipientPolicyManager, @@ -513,6 +514,147 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes Ok(()) } +#[test] +fn desktop_otp_boundary_uses_rfc_codes_and_commits_import_replace_and_removal() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let recipients = RecipientPolicyManager::new(&repository, &keys) + .resolve_for_entry(&EntryPath::parse("desktop/rfc-totp")?, None)?; + let secret = BASE32_NOPAD.encode(b"12345678901234567890"); + for (entry, plaintext) in [ + ( + "desktop/rfc-totp", + format!( + "password\notpauth://totp/RFC6238?secret={secret}&algorithm=SHA1&digits=8&period=30\n" + ), + ), + ( + "desktop/rfc-hotp", + format!("password\notpauth://hotp/RFC4226?secret={secret}&counter=0\n"), + ), + ] { + repository.write_entry( + &EntryPath::parse(entry)?, + &keys.encrypt( + SecretBytes::new(plaintext.into_bytes()), + recipients.recipients(), + )?, + )?; + } + GitRepository::init(&repository, GitIdentity::ironstorage())?; + let config_path = store.path().join("desktop-config.toml"); + fs::write( + &config_path, + format!( + "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", + store.path(), + fixture.generated.keys[0].primary_fingerprint, + fixture.path("keys"), + ), + )?; + let desktop = DesktopStorage::load(Some(&config_path))?; + let mut provider = FixtureSecrets::all(&fixture); + + let totp = desktop.otp_code("desktop/rfc-totp", 59, false, &mut provider)?; + assert_eq!(totp.code().expose(), b"94287082"); + assert_eq!( + totp.validity(), + OtpCodeValidity::Timed { + valid_until: 60, + period: 30, + } + ); + assert_eq!(totp.metadata().kind(), OtpKind::Totp); + assert_eq!(totp.metadata().digits(), 8); + + let hotp_path = EntryPath::parse("desktop/rfc-hotp")?; + let before = repository.read_entry(&hotp_path)?; + let denied = match desktop.otp_code("desktop/rfc-hotp", 59, false, &mut provider) { + Ok(_) => panic!("HOTP requires confirmation"), + Err(error) => error, + }; + assert_eq!(denied.kind(), DesktopErrorKind::Otp); + assert_eq!(repository.read_entry(&hotp_path)?, before); + let hotp = desktop.otp_code("desktop/rfc-hotp", 59, true, &mut provider)?; + assert_eq!(hotp.code().expose(), b"287082"); + assert_eq!( + hotp.validity(), + OtpCodeValidity::CounterBased { counter: 1 } + ); + let (_, _, _, tree, document) = hotp.into_parts(); + assert!(tree.is_some()); + assert_eq!( + document + .expect("HOTP returns refreshed document") + .fields() + .iter() + .find_map(|field| field.metadata().otp()) + .and_then(|otp| otp.counter()), + Some(1) + ); + + let replacement = SecretBytes::new( + b"otpauth://totp/Replaced:alice?secret=JBSWY3DPEHPK3PXP&issuer=Replaced".to_vec(), + ); + let before = repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?; + assert!( + desktop + .import_otp( + "desktop/rfc-totp", + SecretBytes::new(replacement.expose().to_vec()), + OverwriteDecision::Decline, + &mut provider, + ) + .is_err() + ); + assert_eq!( + repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?, + before + ); + assert!( + desktop + .import_otp_qr( + "desktop/rfc-totp", + SecretBytes::new(b"not an image".to_vec()), + OverwriteDecision::Allow, + &mut provider, + ) + .is_err() + ); + assert_eq!( + repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?, + before + ); + let replaced = desktop.import_otp( + "desktop/rfc-totp", + replacement, + OverwriteDecision::Allow, + &mut provider, + )?; + assert_eq!( + replaced + .into_parts() + .1 + .fields() + .iter() + .find_map(|field| field.metadata().otp()) + .and_then(|otp| otp.issuer()), + Some("Replaced") + ); + let removed = desktop.remove_otp("desktop/rfc-totp", &mut provider)?; + assert!( + removed + .into_parts() + .1 + .fields() + .iter() + .all(|field| field.metadata().kind() != ironstorage::document::EntryFieldKind::OtpUri) + ); + Ok(()) +} + #[test] fn read_only_totp_ignores_git_that_mutations_correctly_reject() -> TestResult { let fixture = FixtureSet::load()?; diff --git a/crates/storage/tests/presentation.rs b/crates/storage/tests/presentation.rs index f6cd792..250a6fa 100644 --- a/crates/storage/tests/presentation.rs +++ b/crates/storage/tests/presentation.rs @@ -2,6 +2,7 @@ use std::{ error::Error, + io::Cursor, sync::{Arc, Mutex}, time::Duration, }; @@ -229,3 +230,39 @@ fn qr_matrices_round_trip_and_render_without_plaintext() -> TestResult { )); Ok(()) } + +#[test] +fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResult { + let payload = SecretBytes::new( + b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example".to_vec(), + ); + let matrix = QrMatrix::encode(&payload)?; + let scale = 8_u32; + let quiet = 4_u32; + let size = (matrix.width() as u32 + quiet * 2) * scale; + let mut image = image::GrayImage::from_pixel(size, size, image::Luma([255])); + for y in 0..matrix.width() { + for x in 0..matrix.width() { + if matrix.is_dark(x, y) == Some(true) { + for dy in 0..scale { + for dx in 0..scale { + image.put_pixel( + (x as u32 + quiet) * scale + dx, + (y as u32 + quiet) * scale + dy, + image::Luma([0]), + ); + } + } + } + } + } + let mut encoded = Cursor::new(Vec::new()); + image.write_to(&mut encoded, image::ImageFormat::Png)?; + let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?; + assert_eq!(decoded.expose(), payload.expose()); + assert!(matches!( + QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())), + Err(QrError::InvalidImage) + )); + Ok(()) +}