Present desktop authentication and automatic relocking

This commit is contained in:
2026-08-10 15:21:20 +02:00
parent e0ce21337d
commit b9034c5274
8 changed files with 540 additions and 28 deletions

3
Cargo.lock generated
View File

@@ -3653,6 +3653,7 @@ dependencies = [
"iced_core", "iced_core",
"log", "log",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"tokio",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasmtimer", "wasmtimer",
] ]
@@ -3989,6 +3990,7 @@ dependencies = [
"hex", "hex",
"hmac", "hmac",
"keyring-core", "keyring-core",
"nix 0.31.3",
"pgp", "pgp",
"qrcode", "qrcode",
"rand 0.8.7", "rand 0.8.7",
@@ -3996,7 +3998,6 @@ dependencies = [
"regex", "regex",
"reqwest", "reqwest",
"rqrr", "rqrr",
"rustix 1.1.4",
"secret-service", "secret-service",
"security-framework", "security-framework",
"serde", "serde",

View File

@@ -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 = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
gix-config = "0.59" gix-config = "0.59"
hmac = "0.12" hmac = "0.12"
iced = "0.14" iced = { version = "0.14", features = ["tokio"] }
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
keyring-core = "1.0" keyring-core = "1.0"
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }

View File

@@ -41,6 +41,7 @@ use ironstorage::{
secret_store::{ secret_store::{
NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
SecretStoreError,
}, },
write::{ write::{
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter, EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
@@ -65,6 +66,26 @@ fn run() -> Result<u8, ()> {
} }
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()> fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + 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<I, T, O, E>(
arguments: I,
mut stdout: O,
mut stderr: E,
open_secret_store: impl FnOnce() -> Result<NativeSecretStore, SecretStoreError>,
) -> Result<u8, ()>
where where
I: IntoIterator<Item = T>, I: IntoIterator<Item = T>,
T: Into<OsString> + Clone, T: Into<OsString> + Clone,
@@ -122,11 +143,7 @@ where
} }
request => match Config::load(invocation.config()) { request => match Config::load(invocation.config()) {
Ok(config) if needs_secret_store(request) => { Ok(config) if needs_secret_store(request) => {
let mut secrets = match NativeSecretStore::system( let mut secrets = match open_secret_store().and_then(|store| {
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
.and_then(|store| {
store.unlock()?; store.unlock()?;
Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt)) Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
}) { }) {
@@ -1533,7 +1550,7 @@ mod tests {
use super::{ use super::{
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local, CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local,
execute_secure, execute_secure_with, execute_secure_with_services, run_with, 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<dyn Error>>; type TestResult = Result<(), Box<dyn Error>>;
@@ -1803,7 +1820,7 @@ mod tests {
)?; )?;
let mut stdout = Vec::new(); let mut stdout = Vec::new();
let mut stderr = Vec::new(); let mut stderr = Vec::new();
let code = run_with( let code = run_with_secret_store(
[ [
OsString::from("ironstorage"), OsString::from("ironstorage"),
OsString::from("--config"), OsString::from("--config"),
@@ -1813,6 +1830,7 @@ mod tests {
], ],
&mut stdout, &mut stdout,
&mut stderr, &mut stderr,
|| Err(SecretStoreError::Unavailable),
) )
.expect("writing to memory cannot fail"); .expect("writing to memory cannot fail");
assert_eq!(code, EXIT_UNAVAILABLE); assert_eq!(code, EXIT_UNAVAILABLE);

View File

@@ -1,23 +1,511 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)] #![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)] #[derive(Debug, Clone)]
enum Message {} enum Message {
StartupLoaded(Result<(NativeAuthenticationSession, KeyInfo), String>),
UnlockProtectedContent,
AuthenticationFinished {
generation: u64,
result: Result<NativeAuthenticationHandle, String>,
},
UserActivity,
Tick,
Lock,
}
#[derive(Debug)]
enum AuthenticationView {
Loading,
Locked,
Authenticating,
Unlocked(Duration),
Unavailable(String),
}
#[derive(Default)]
struct SensitiveUiState {
decrypted: Option<SecretBytes>,
editor: Option<SecretBytes>,
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<NativeAuthenticationSession>,
key: Option<KeyInfo>,
handle: Option<NativeAuthenticationHandle>,
sensitive: SensitiveUiState,
generation: u64,
status: String,
}
#[derive(Debug, Eq, PartialEq)]
enum LeasePoll {
Idle,
Active(Duration),
Expired,
}
fn main() -> iced::Result { fn main() -> iced::Result {
iced::application(|| (), update, view) iced::application(App::new, App::update, App::view)
.title(ironstorage::PRODUCT_NAME) .title(ironstorage::PRODUCT_NAME)
.subscription(App::subscription)
.run() .run()
} }
fn update(_: &mut (), message: Message) { impl App {
match message {} fn new() -> (Self, Task<Message>) {
(
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<Message> {
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<Message> {
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> { async fn load_authentication() -> Result<(NativeAuthenticationSession, KeyInfo), String> {
container(text("No password store is open.")) let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
.center(Length::Fill) let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?;
.into() 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<B: SecretStoreBackend, C: AuthenticationClock>(
session: &AuthenticationSession<B, C>,
handle: &mut Option<AuthenticationHandle<B, C>>,
sensitive: &mut SensitiveUiState,
) -> Result<LeasePoll, AuthenticationError> {
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<Mutex<Duration>>);
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<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
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<SecretBytes, SecretStoreError> {
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<MemoryBackend, ManualClock>,
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))
);
}
} }

View File

@@ -47,8 +47,8 @@ arboard.workspace = true
[dev-dependencies] [dev-dependencies]
hex = "0.4" hex = "0.4"
nix = { version = "0.31", features = ["fs"] }
rand_chacha = "0.3" rand_chacha = "0.3"
rqrr.workspace = true rqrr.workspace = true
rustix = { version = "1.1", features = ["fs"] }
smallvec = "1.15" smallvec = "1.15"
tempfile = "3" tempfile = "3"

View File

@@ -63,8 +63,11 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
.loader() .loader()
.load(Some(Path::new("config/config.toml")))?; .load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.source(), fixture.explicit_path()); assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?);
assert_eq!(config.vault(), fixture.temporary.path().join("cwd/vault")); assert_eq!(
config.vault(),
fs::canonicalize(fixture.temporary.path())?.join("cwd/vault")
);
assert_eq!( assert_eq!(
config.key_material(), config.key_material(),
fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))? 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())?; fs::write(&default, fixture.valid_contents())?;
let config = fixture.loader().load(None)?; let config = fixture.loader().load(None)?;
assert_eq!(config.source(), default); assert_eq!(config.source(), fs::canonicalize(default)?);
Ok(()) Ok(())
} }

View File

@@ -110,7 +110,10 @@ fn nested_repository_selection_is_innermost() -> TestResult {
let selected = let selected =
GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?; 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(()) Ok(())
} }

View File

@@ -155,7 +155,7 @@ fn hostile_logical_paths_are_rejected_before_filesystem_access() {
fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult { fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
use rustix::fs::{CWD, Mode, mkfifoat}; use nix::{sys::stat::Mode, unistd::mkfifo};
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
let vault = temporary.path().join("vault"); 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"); assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside");
fs::remove_file(vault.join("escape.gpg"))?; fs::remove_file(vault.join("escape.gpg"))?;
mkfifoat( mkfifo(
CWD, &vault.join("unsupported.gpg"),
vault.join("unsupported.gpg"), Mode::from_bits_truncate(0o600),
Mode::from_raw_mode(0o600),
)?; )?;
assert!(matches!( assert!(matches!(
repository.snapshot(), repository.snapshot(),