Present desktop authentication and automatic relocking
This commit is contained in:
@@ -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<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 {
|
||||
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<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> {
|
||||
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<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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user