From b9034c527469f83cd51ba9147a1bd3a4c9774c8f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 15:21:20 +0200 Subject: [PATCH] Present desktop authentication and automatic relocking --- Cargo.lock | 3 +- Cargo.toml | 2 +- apps/cli/src/main.rs | 32 +- apps/desktop/src/main.rs | 506 +++++++++++++++++++++++- crates/storage/Cargo.toml | 2 +- crates/storage/tests/config_contract.rs | 9 +- crates/storage/tests/git_embedded.rs | 5 +- crates/storage/tests/repository_core.rs | 9 +- 8 files changed, 540 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8962024..671c786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3653,6 +3653,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.3", + "tokio", "wasm-bindgen-futures", "wasmtimer", ] @@ -3989,6 +3990,7 @@ dependencies = [ "hex", "hmac", "keyring-core", + "nix 0.31.3", "pgp", "qrcode", "rand 0.8.7", @@ -3996,7 +3998,6 @@ dependencies = [ "regex", "reqwest", "rqrr", - "rustix 1.1.4", "secret-service", "security-framework", "serde", diff --git a/Cargo.toml b/Cargo.toml index a9d7b5b..a0fc6a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ 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 = "0.14" +iced = { version = "0.14", features = ["tokio"] } ironstorage = { path = "crates/storage" } keyring-core = "1.0" pgp = { version = "0.20", default-features = false } diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 64ebd3e..11f43bc 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -41,6 +41,7 @@ use ironstorage::{ secret_store::{ NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, + SecretStoreError, }, write::{ EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, @@ -65,6 +66,26 @@ fn run() -> Result { } fn run_with(arguments: I, mut stdout: O, mut stderr: E) -> Result +where + I: IntoIterator, + T: Into + Clone, + O: Write, + E: Write, +{ + run_with_secret_store(arguments, &mut stdout, &mut stderr, || { + NativeSecretStore::system( + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) + }) +} + +fn run_with_secret_store( + arguments: I, + mut stdout: O, + mut stderr: E, + open_secret_store: impl FnOnce() -> Result, +) -> Result where I: IntoIterator, T: Into + Clone, @@ -122,11 +143,7 @@ where } request => match Config::load(invocation.config()) { Ok(config) if needs_secret_store(request) => { - let mut secrets = match NativeSecretStore::system( - SecretCachePolicy::Disabled, - SecretProtectionPolicy::device_unlocked(), - ) - .and_then(|store| { + let mut secrets = match open_secret_store().and_then(|store| { store.unlock()?; Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt)) }) { @@ -1533,7 +1550,7 @@ mod tests { use super::{ CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local, execute_secure, execute_secure_with, execute_secure_with_services, run_with, - wait_for_clipboard, + run_with_secret_store, wait_for_clipboard, }; type TestResult = Result<(), Box>; @@ -1803,7 +1820,7 @@ mod tests { )?; let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let code = run_with( + let code = run_with_secret_store( [ OsString::from("ironstorage"), OsString::from("--config"), @@ -1813,6 +1830,7 @@ mod tests { ], &mut stdout, &mut stderr, + || Err(SecretStoreError::Unavailable), ) .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_UNAVAILABLE); diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 31ffd17..82929cf 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -1,23 +1,511 @@ #![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] -use iced::{Element, Length, widget::container, widget::text}; +use std::time::Duration; + +use iced::{ + Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch, + widget::{button, column, container, text}, +}; +use ironstorage::{ + authentication::{ + AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, + NativeAuthenticationHandle, NativeAuthenticationSession, + }, + crypto::{KeyInfo, KeyStore}, + repository::SecretBytes, + secret_store::{SecretProtectionPolicy, SecretStoreBackend}, +}; #[derive(Debug, Clone)] -enum Message {} +enum Message { + StartupLoaded(Result<(NativeAuthenticationSession, KeyInfo), String>), + UnlockProtectedContent, + AuthenticationFinished { + generation: u64, + result: Result, + }, + UserActivity, + Tick, + Lock, +} + +#[derive(Debug)] +enum AuthenticationView { + Loading, + Locked, + Authenticating, + Unlocked(Duration), + Unavailable(String), +} + +#[derive(Default)] +struct SensitiveUiState { + decrypted: Option, + editor: Option, + clipboard_presenting: bool, +} + +impl SensitiveUiState { + fn clear(&mut self) { + self.decrypted = None; + self.editor = None; + self.clipboard_presenting = false; + } + + #[cfg(test)] + fn is_clear(&self) -> bool { + self.decrypted.is_none() && self.editor.is_none() && !self.clipboard_presenting + } +} + +struct App { + authentication: AuthenticationView, + session: Option, + key: Option, + handle: Option, + sensitive: SensitiveUiState, + generation: u64, + status: String, +} + +#[derive(Debug, Eq, PartialEq)] +enum LeasePoll { + Idle, + Active(Duration), + Expired, +} fn main() -> iced::Result { - iced::application(|| (), update, view) + iced::application(App::new, App::update, App::view) .title(ironstorage::PRODUCT_NAME) + .subscription(App::subscription) .run() } -fn update(_: &mut (), message: Message) { - match message {} +impl App { + fn new() -> (Self, Task) { + ( + Self { + authentication: AuthenticationView::Loading, + session: None, + key: None, + handle: None, + sensitive: SensitiveUiState::default(), + generation: 0, + status: "Loading shared configuration…".to_owned(), + }, + Task::perform(load_authentication(), Message::StartupLoaded), + ) + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::StartupLoaded(Ok((session, key))) => { + self.session = Some(session); + self.key = Some(key); + self.authentication = AuthenticationView::Locked; + self.status = "No password store content is unlocked.".to_owned(); + } + Message::StartupLoaded(Err(error)) => { + self.authentication = AuthenticationView::Unavailable(error.clone()); + self.status = error; + } + Message::UnlockProtectedContent => { + let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else { + return Task::none(); + }; + if matches!( + self.authentication, + AuthenticationView::Authenticating | AuthenticationView::Unlocked(_) + ) { + return Task::none(); + } + self.generation = self.generation.wrapping_add(1); + let generation = self.generation; + self.authentication = AuthenticationView::Authenticating; + self.status = "Waiting for secure-storage authentication…".to_owned(); + return Task::perform( + async move { + session + .authenticate(&key) + .map_err(|error| error.to_string()) + }, + move |result| Message::AuthenticationFinished { generation, result }, + ); + } + Message::AuthenticationFinished { generation, result } => { + if generation != self.generation { + if result.is_ok() + && let Some(session) = &self.session + { + let _ignored = session.manual_lock(); + } + return Task::none(); + } + match result { + Ok(handle) => match handle.remaining_time() { + Ok(remaining) => { + self.handle = Some(handle); + self.authentication = AuthenticationView::Unlocked(remaining); + self.status = "Protected content is unlocked.".to_owned(); + } + Err(error) => self.authentication_lost(error.to_string()), + }, + Err(error) => { + self.authentication_lost(error.clone()); + self.status = format!("Authentication failed: {error}"); + } + } + } + Message::UserActivity => { + if let Some(handle) = &self.handle { + match handle.touch_user_activity() { + Ok(()) => { + if let Ok(remaining) = handle.remaining_time() { + self.authentication = AuthenticationView::Unlocked(remaining); + } + } + Err(error) => self.authentication_lost(error.to_string()), + } + } + } + Message::Tick => { + if let Some(session) = &self.session { + match poll_lease(session, &mut self.handle, &mut self.sensitive) { + Ok(LeasePoll::Active(remaining)) => { + self.authentication = AuthenticationView::Unlocked(remaining); + } + Ok(LeasePoll::Expired) => { + self.authentication_lost("the authentication lease expired".to_owned()); + } + Ok(LeasePoll::Idle) => {} + Err(error) => self.authentication_lost(error.to_string()), + } + } + } + Message::Lock => { + self.generation = self.generation.wrapping_add(1); + let result = self + .session + .as_ref() + .map_or(Ok(()), NativeAuthenticationSession::manual_lock); + self.authentication_lost("manually locked".to_owned()); + if let Err(error) = result { + self.authentication = AuthenticationView::Unavailable(error.to_string()); + self.status = format!("Lock failed: {error}"); + } + } + } + Task::none() + } + + fn authentication_lost(&mut self, reason: String) { + self.handle = None; + self.sensitive.clear(); + self.authentication = AuthenticationView::Locked; + self.status = reason; + } + + fn subscription(&self) -> Subscription { + Subscription::batch([ + time::every(Duration::from_secs(1)).map(|_| Message::Tick), + event::listen_with(|event, _status, _window| { + is_deliberate_activity(&event).then_some(Message::UserActivity) + }), + ]) + } + + fn view(&self) -> Element<'_, Message> { + let (heading, detail, action) = match &self.authentication { + AuthenticationView::Loading => ( + "Loading", + "Reading the shared IronStorage configuration.", + None, + ), + AuthenticationView::Locked => ( + "Locked", + "Encrypted entry names remain browsable. Protected content requires authentication.", + Some(button("Unlock protected content").on_press(Message::UnlockProtectedContent)), + ), + AuthenticationView::Authenticating => ( + "Authenticating", + "Complete or cancel the native secure-storage prompt.", + Some(button("Cancel and lock").on_press(Message::Lock)), + ), + AuthenticationView::Unlocked(remaining) => ( + "Unlocked", + if remaining.as_secs() == 1 { + "Authentication expires after 1 second of inactivity." + } else { + "Protected content is available until the inactivity lease expires." + }, + Some(button("Lock now").on_press(Message::Lock)), + ), + AuthenticationView::Unavailable(error) => ("Unavailable", error.as_str(), None), + }; + let remaining = match self.authentication { + AuthenticationView::Unlocked(remaining) => { + format!("{} seconds remaining", remaining.as_secs()) + } + _ => String::new(), + }; + let mut content = column![ + text(heading).size(28), + text(detail), + text(remaining), + text(&self.status).size(14), + ] + .spacing(12); + if let Some(action) = action { + content = content.push(action); + } + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill) + .into() + } } -fn view(_: &()) -> Element<'_, Message> { - container(text("No password store is open.")) - .center(Length::Fill) - .into() +async fn load_authentication() -> Result<(NativeAuthenticationSession, KeyInfo), String> { + let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; + let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; + let handle = keys + .resolve(config.default_key().as_str()) + .map_err(|error| error.to_string())?; + let key = keys + .infos() + .find(|key| key.fingerprint() == handle.fingerprint()) + .ok_or_else(|| "the configured GPG key is unavailable".to_owned())?; + let session = NativeAuthenticationSession::system( + SecretProtectionPolicy::default(), + config.authentication_timeout(), + ) + .map_err(|error| error.to_string())?; + Ok((session, key)) +} + +fn poll_lease( + session: &AuthenticationSession, + handle: &mut Option>, + sensitive: &mut SensitiveUiState, +) -> Result { + if session.expire()? { + *handle = None; + sensitive.clear(); + return Ok(LeasePoll::Expired); + } + handle + .as_ref() + .map(AuthenticationHandle::remaining_time) + .transpose() + .map(|remaining| remaining.map_or(LeasePoll::Idle, LeasePoll::Active)) +} + +fn is_deliberate_activity(event: &Event) -> bool { + matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { .. }) + | Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }) + | Event::Touch( + touch::Event::FingerPressed { .. } + | touch::Event::FingerMoved { .. } + | touch::Event::FingerLifted { .. } + ) + ) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + }; + + use iced::{Point, window}; + use ironstorage::{ + authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, + secret_store::{ + SecretCachePolicy, SecretLocator, SecretProtection, SecretReference, SecretStore, + SecretStoreError, + }, + }; + + use super::*; + + #[derive(Clone, Default)] + struct ManualClock(Arc>); + + impl ManualClock { + fn advance(&self, duration: Duration) { + *self.0.lock().expect("test clock") += duration; + } + } + + impl AuthenticationClock for ManualClock { + fn now(&self) -> Duration { + *self.0.lock().expect("test clock") + } + } + + #[derive(Clone, Default)] + struct MemoryBackend(Arc>>); + + impl SecretStoreBackend for MemoryBackend { + fn create( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.0 + .lock() + .expect("test backend") + .insert(locator.clone(), SecretBytes::new(value.to_vec())); + Ok(()) + } + + fn retrieve( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + ) -> Result { + self.0 + .lock() + .expect("test backend") + .get(locator) + .map(|value| SecretBytes::new(value.expose().to_vec())) + .ok_or(SecretStoreError::Missing) + } + + fn replace( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.create(locator, SecretProtection::DeviceUnlocked, value) + } + + fn delete( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + self.0.lock().expect("test backend").remove(locator); + Ok(()) + } + + fn lock(&self) -> Result<(), SecretStoreError> { + Ok(()) + } + + fn unlock(&self) -> Result<(), SecretStoreError> { + Ok(()) + } + } + + fn session( + timeout: Duration, + ) -> ( + AuthenticationSession, + KeyInfo, + ManualClock, + ) { + let mut keys = KeyStore::new(); + let [key] = keys + .import(include_bytes!( + "../../../crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc" + )) + .expect("fixture key") + .try_into() + .expect("one fixture key"); + let backend = MemoryBackend::default(); + let store = SecretStore::new( + backend.clone(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock().expect("unlock fixture store"); + store + .create( + &SecretReference::openpgp_passphrase(key.fingerprint().as_str()) + .expect("fixture reference"), + SecretBytes::new(b"fixture-alice-passphrase".to_vec()), + ) + .expect("provision fixture passphrase"); + store.lock().expect("lock fixture store"); + let clock = ManualClock::default(); + let session = AuthenticationSession::with_clock( + backend, + SecretProtectionPolicy::device_unlocked(), + AuthenticationTimeout::new(timeout).expect("valid timeout"), + clock.clone(), + ); + (session, key, clock) + } + + fn sensitive_state() -> SensitiveUiState { + SensitiveUiState { + decrypted: Some(SecretBytes::new(b"decrypted".to_vec())), + editor: Some(SecretBytes::new(b"dirty draft".to_vec())), + clipboard_presenting: true, + } + } + + #[test] + fn default_and_configured_timeouts_clear_all_sensitive_ui_state() { + for timeout in [DEFAULT_AUTHENTICATION_TIMEOUT, Duration::from_secs(7)] { + let (session, key, clock) = session(timeout); + let mut handle = Some(session.authenticate(&key).expect("authenticate")); + let mut sensitive = sensitive_state(); + + clock.advance(timeout); + + assert_eq!( + poll_lease(&session, &mut handle, &mut sensitive).expect("poll lease"), + LeasePoll::Expired + ); + assert!(handle.is_none()); + assert!(sensitive.is_clear()); + } + } + + #[test] + fn only_deliberate_input_renews_the_storage_owned_lease() { + let timeout = Duration::from_secs(10); + let (session, key, clock) = session(timeout); + let mut handle = Some(session.authenticate(&key).expect("authenticate")); + let mut sensitive = sensitive_state(); + + assert!(!is_deliberate_activity(&Event::Window( + window::Event::Focused + ))); + assert!(!is_deliberate_activity(&Event::Mouse( + mouse::Event::CursorMoved { + position: Point::ORIGIN, + } + ))); + clock.advance(timeout); + assert_eq!( + poll_lease(&session, &mut handle, &mut sensitive).expect("passive poll"), + LeasePoll::Expired + ); + + handle = Some(session.authenticate(&key).expect("reauthenticate")); + clock.advance(Duration::from_secs(9)); + let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)); + assert!(is_deliberate_activity(&click)); + handle + .as_ref() + .expect("active handle") + .touch_user_activity() + .expect("renew from deliberate input"); + clock.advance(Duration::from_secs(9)); + assert_eq!( + poll_lease(&session, &mut handle, &mut sensitive).expect("active poll"), + LeasePoll::Active(Duration::from_secs(1)) + ); + } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 895e231..d6b1e30 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -47,8 +47,8 @@ arboard.workspace = true [dev-dependencies] hex = "0.4" +nix = { version = "0.31", features = ["fs"] } rand_chacha = "0.3" rqrr.workspace = true -rustix = { version = "1.1", features = ["fs"] } smallvec = "1.15" tempfile = "3" diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index ade8105..d606943 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -63,8 +63,11 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { .loader() .load(Some(Path::new("config/config.toml")))?; - assert_eq!(config.source(), fixture.explicit_path()); - assert_eq!(config.vault(), fixture.temporary.path().join("cwd/vault")); + assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?); + assert_eq!( + config.vault(), + fs::canonicalize(fixture.temporary.path())?.join("cwd/vault") + ); assert_eq!( config.key_material(), fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))? @@ -170,7 +173,7 @@ fn native_default_path_is_used_without_an_explicit_path() -> TestResult { fs::write(&default, fixture.valid_contents())?; let config = fixture.loader().load(None)?; - assert_eq!(config.source(), default); + assert_eq!(config.source(), fs::canonicalize(default)?); Ok(()) } diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index b9c3822..40b7ba7 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -110,7 +110,10 @@ fn nested_repository_selection_is_innermost() -> TestResult { let selected = GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?; - assert_eq!(selected.root(), temporary.path().join("nested")); + assert_eq!( + selected.root(), + fs::canonicalize(temporary.path().join("nested"))? + ); Ok(()) } diff --git a/crates/storage/tests/repository_core.rs b/crates/storage/tests/repository_core.rs index af975b8..432371d 100644 --- a/crates/storage/tests/repository_core.rs +++ b/crates/storage/tests/repository_core.rs @@ -155,7 +155,7 @@ fn hostile_logical_paths_are_rejected_before_filesystem_access() { fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult { use std::os::unix::fs::symlink; - use rustix::fs::{CWD, Mode, mkfifoat}; + use nix::{sys::stat::Mode, unistd::mkfifo}; let temporary = tempfile::tempdir()?; let vault = temporary.path().join("vault"); @@ -174,10 +174,9 @@ fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult { assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside"); fs::remove_file(vault.join("escape.gpg"))?; - mkfifoat( - CWD, - vault.join("unsupported.gpg"), - Mode::from_raw_mode(0o600), + mkfifo( + &vault.join("unsupported.gpg"), + Mode::from_bits_truncate(0o600), )?; assert!(matches!( repository.snapshot(),