//! Shared mobile authentication state; Swift only supplies input and presents results. use std::{error::Error, fmt, sync::Mutex}; use crate::{ authentication::{ AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession, }, config::{Config, ConfigError}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, git::{AutomaticEntryCommitter, GitIdentity}, mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value}, repository::{EntryPath, Repository, RepositoryError, SecretBytes}, secret_store::{SecretProtectionPolicy, SecretStoreError}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileAuthenticationErrorKind { PassphraseRequired, InvalidPassphrase, Cancelled, BiometryUnavailable, Configuration, KeyMaterial, Entry, SecureStorage, Expired, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileAuthenticationError { kind: MobileAuthenticationErrorKind, title: String, detail: String, } impl MobileAuthenticationError { pub fn kind(&self) -> MobileAuthenticationErrorKind { self.kind } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into) -> Self { Self { kind, title: title.to_owned(), detail: detail.into(), } } fn passphrase_required(detail: impl Into) -> Self { Self::new( MobileAuthenticationErrorKind::PassphraseRequired, "Passphrase Required", detail, ) } fn authentication(error: AuthenticationError) -> Self { match error { AuthenticationError::Cancelled => Self::new( MobileAuthenticationErrorKind::Cancelled, "Authentication Cancelled", "No protected content was unlocked.", ), AuthenticationError::Expired => Self::new( MobileAuthenticationErrorKind::Expired, "IronStorage Locked", "The authentication lease expired.", ), AuthenticationError::SecretStore(SecretStoreError::Missing) => { Self::passphrase_required("Enter the GPG key passphrase to continue.") } AuthenticationError::SecretStore( SecretStoreError::Denied | SecretStoreError::UnsupportedProtection | SecretStoreError::Unavailable, ) => Self::new( MobileAuthenticationErrorKind::BiometryUnavailable, "Biometric Unlock Unavailable", "Enter the GPG key passphrase to recover or continue without biometric unlock.", ), error => Self::new( MobileAuthenticationErrorKind::SecureStorage, "Secure Unlock Failed", error.to_string(), ), } } } impl fmt::Display for MobileAuthenticationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", self.title, self.detail) } } impl Error for MobileAuthenticationError {} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MobileAuthenticationState { unlocked: bool, biometric_unlock_enabled: bool, remaining_seconds: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileEntryCopy { value: String, timeout_seconds: u64, } impl MobileEntryCopy { pub fn value(&self) -> &str { &self.value } pub fn timeout_seconds(&self) -> u64 { self.timeout_seconds } } impl MobileAuthenticationState { pub fn unlocked(self) -> bool { self.unlocked } pub fn biometric_unlock_enabled(self) -> bool { self.biometric_unlock_enabled } pub fn remaining_seconds(self) -> u64 { self.remaining_seconds } } struct ActiveMobileLease { handle: NativeAuthenticationHandle, key: KeyInfo, } struct MobileAuthenticationStatus { biometric_unlock_enabled: bool, active: Option, } /// One process-wide mobile authentication lease shared by every tab and viewer. pub struct MobileAuthentication { config: Config, repository: Repository, keys: KeyStore, session: NativeAuthenticationSession, status: Mutex, } impl MobileAuthentication { pub fn load() -> Result { let config = Config::load(None).map_err(|error| { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Configuration, "Authentication Is Unavailable", error.to_string(), ) })?; let repository = Repository::open(config.vault()).map_err(entry_error)?; let keys = KeyStore::load(config.key_material()).map_err(key_error)?; let session = NativeAuthenticationSession::system( SecretProtectionPolicy::current_biometry_for_openpgp(), config.authentication_timeout(), ) .map_err(MobileAuthenticationError::authentication)?; Ok(Self { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), active: None, }), config, repository, keys, session, }) } /// Validate an entry unlock and retain only the key passphrase for the shared lease. pub fn unlock_entry( &self, path: &str, passphrase: Option, ) -> Result { let entry = EntryPath::parse(path).map_err(entry_error)?; let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?; let candidates = self.keys.decrypting_keys(&ciphertext).map_err(key_error)?; if let Some(active) = self.take_active()? { let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key); if self.keys.decrypt(&ciphertext, &mut provider).is_ok() { self.restore_active(active)?; return self.state(); } self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; } let biometric_enabled = self.status()?.biometric_unlock_enabled; match passphrase { Some(passphrase) => { for key in &candidates { let candidate = SecretBytes::new(passphrase.expose().to_vec()); let handle = self .session .authenticate_with_passphrase(key, candidate) .map_err(MobileAuthenticationError::authentication)?; let mut provider = KeyOnlyProvider::new(handle.clone(), key); match self.keys.decrypt(&ciphertext, &mut provider) { Ok(plaintext) => { drop(plaintext); if biometric_enabled { handle .persist_passphrase(key) .map_err(MobileAuthenticationError::authentication)?; } self.set_active(handle, key.clone())?; return self.state(); } Err(CryptoError::DecryptionFailed) => { self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; } Err(error) => return Err(key_error(error)), } } Err(MobileAuthenticationError::new( MobileAuthenticationErrorKind::InvalidPassphrase, "Incorrect Passphrase", "The GPG key could not be unlocked.", )) } None if biometric_enabled => { for key in &candidates { let handle = match self.session.authenticate(key) { Ok(handle) => handle, Err(AuthenticationError::Cancelled) => { return Err(MobileAuthenticationError::authentication( AuthenticationError::Cancelled, )); } Err(_) => continue, }; let mut provider = KeyOnlyProvider::new(handle.clone(), key); if let Ok(plaintext) = self.keys.decrypt(&ciphertext, &mut provider) { drop(plaintext); self.set_active(handle, key.clone())?; return self.state(); } let _ = self.session.delete_key_passphrase(key); let _ = self.session.manual_lock(); } Err(MobileAuthenticationError::passphrase_required( "Biometric unlock could not restore this key. Enter its GPG passphrase to recover.", )) } None => Err(MobileAuthenticationError::passphrase_required( "Enter the GPG key passphrase to continue.", )), } } pub fn set_biometric_unlock( &self, enabled: bool, ) -> Result { if enabled { let status = self.status()?; if status.biometric_unlock_enabled { drop(status); return self.state(); } let active = status.active.as_ref().ok_or_else(|| { MobileAuthenticationError::passphrase_required( "Unlock a password entry before enabling biometric unlock.", ) })?; active .handle .persist_passphrase(&active.key) .map_err(MobileAuthenticationError::authentication)?; drop(status); if let Err(error) = self.config.update_biometric_unlock(true) { let _ = self.delete_all_key_passphrases(); let _ = self.manual_lock(); return Err(config_error(error)); } self.status()?.biometric_unlock_enabled = true; } else { self.delete_all_key_passphrases()?; self.config .update_biometric_unlock(false) .map_err(config_error)?; self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; let mut status = self.status()?; status.biometric_unlock_enabled = false; status.active = None; } self.state() } pub fn state(&self) -> Result { let remaining = self .session .remaining_time() .map_err(MobileAuthenticationError::authentication)?; let mut status = self.status()?; if remaining.is_none() { status.active = None; } Ok(MobileAuthenticationState { unlocked: status.active.is_some(), biometric_unlock_enabled: status.biometric_unlock_enabled, remaining_seconds: remaining.map_or(0, |duration| duration.as_secs()), }) } pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> { let status = self.status()?; let active = status.active.as_ref().ok_or_else(|| { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Expired, "IronStorage Locked", "Authenticate before using protected content.", ) })?; active .handle .touch_user_activity() .map_err(MobileAuthenticationError::authentication) } pub fn entry_page(&self, path: &str) -> Result { let document = self.open_active_document(path)?; Ok(MobileEntryPage::from_document(&document)) } pub fn reveal_entry_field( &self, path: &str, field: u64, ) -> Result { let document = self.open_active_document(path)?; field_value(&document, field).map_err(value_error) } pub fn copy_entry_field( &self, path: &str, field: u64, ) -> Result { Ok(MobileEntryCopy { value: self.reveal_entry_field(path, field)?, timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), }) } pub fn replace_entry_field( &self, path: &str, field: u64, value: String, ) -> Result { let mut document = self.open_active_document(path)?; document .replace_field_value(EntryFieldId::from_value(field), value.into_bytes()) .map_err(document_error)?; let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage()) .map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?; EntryDocumentService::new(&self.repository, &self.keys) .save_recoverable(&document, None, &mut committer) .map_err(document_error)?; Ok(MobileEntryPage::from_document(&document)) } pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; self.status()?.active = None; Ok(()) } pub fn cancel(&self) -> Result<(), MobileAuthenticationError> { self.session .cancel() .map_err(MobileAuthenticationError::authentication)?; self.status()?.active = None; Ok(()) } fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> { for key in self.keys.infos().filter(KeyInfo::has_secret) { self.session .delete_key_passphrase(&key) .map_err(MobileAuthenticationError::authentication)?; } Ok(()) } fn status( &self, ) -> Result, MobileAuthenticationError> { self.status.lock().map_err(|_| { MobileAuthenticationError::new( MobileAuthenticationErrorKind::SecureStorage, "Authentication Is Unavailable", "The shared authentication state is unavailable.", ) }) } fn take_active(&self) -> Result, MobileAuthenticationError> { Ok(self.status()?.active.take()) } fn restore_active(&self, active: ActiveMobileLease) -> Result<(), MobileAuthenticationError> { self.status()?.active = Some(active); Ok(()) } fn set_active( &self, handle: NativeAuthenticationHandle, key: KeyInfo, ) -> Result<(), MobileAuthenticationError> { self.status()?.active = Some(ActiveMobileLease { handle, key }); Ok(()) } fn open_active_document(&self, path: &str) -> Result { let (handle, key) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; (active.handle.clone(), active.key.clone()) }; handle .ensure_active() .map_err(MobileAuthenticationError::authentication)?; let mut provider = KeyOnlyProvider::new(handle, &key); EntryDocumentService::new(&self.repository, &self.keys) .open(path, &mut provider) .map_err(document_error) } } struct KeyOnlyProvider<'a> { handle: NativeAuthenticationHandle, fingerprint: &'a str, } impl<'a> KeyOnlyProvider<'a> { fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self { Self { handle, fingerprint: key.fingerprint().as_str(), } } } impl SecretProvider for KeyOnlyProvider<'_> { fn secret_for(&mut self, key: &KeyInfo) -> Result { if key.fingerprint().as_str() != self.fingerprint { return Err(SecretProviderError::Missing); } self.handle.secret_for(key) } } fn config_error(error: ConfigError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Configuration, "Preference Was Not Saved", error.to_string(), ) } fn entry_error(error: RepositoryError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, "Password Entry Is Unavailable", error.to_string(), ) } fn key_error(error: CryptoError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::KeyMaterial, "GPG Key Unlock Failed", error.to_string(), ) } fn locked_error() -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Expired, "IronStorage Locked", "Authenticate before using protected content.", ) } fn document_error(error: DocumentError) -> MobileAuthenticationError { entry_detail("Password Entry Is Unavailable", error) } fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError { entry_detail("Field Value Is Unavailable", error) } fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, title, error.to_string(), ) }