//! Shared mobile authentication state; native frontends supply input and present results. use std::{ collections::BTreeMap, error::Error, fmt, path::Path, sync::{Arc, Mutex}, time::Duration, }; use crate::{ authentication::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, AuthenticationTimeout, SystemAuthenticationClock, }, config::{Config, ConfigError, GitRemote, MobileAppearance}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, mobile_entry::{ MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind, MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage, MobileEntryValueError, document_revision, field_value, }, mobile_mutation::{ MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan, MobileMutationRequest, MobileMutationService, }, mobile_totp::{ MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, }, mobile_watch::{ MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError, WatchSnapshotSender, WatchSnapshotTransfer, }, otp::OtpError, recipient::RecipientPolicyManager, repository::{ DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, }, secret_store::{ NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend, SecretStoreError, }, write::{VaultWriter, WriteError}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileAuthenticationErrorKind { PassphraseRequired, InvalidPassphrase, Cancelled, BiometryUnavailable, Configuration, KeyMaterial, Entry, SecureStorage, Expired, Conflict, } #[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 } pub(crate) 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 | SecretStoreError::DeviceKeyMissing | SecretStoreError::Invalidated, ) => Self::passphrase_required("Enter the GPG key passphrase to continue."), AuthenticationError::SecretStore( SecretStoreError::Denied | SecretStoreError::Lockout | 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, Copy, Debug, Eq, PartialEq)] pub enum MobileWatchPreferenceState { Unsupported, NotPaired, AppNotInstalled, Ready, Pending, Delivered, Current, Failed, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobilePreferences { sync_configured: bool, repository_title: String, repository_url: String, server_title: String, server_identity: String, application_account: Option, default_key_title: String, default_key_fingerprint: String, authentication_timeout_seconds: u64, biometric_unlock_enabled: bool, appearance: MobileAppearance, watch_state: MobileWatchPreferenceState, watch_title: String, watch_detail: String, } impl MobilePreferences { pub fn sync_configured(&self) -> bool { self.sync_configured } pub fn repository_title(&self) -> &str { &self.repository_title } pub fn repository_url(&self) -> &str { &self.repository_url } pub fn server_title(&self) -> &str { &self.server_title } pub fn server_identity(&self) -> &str { &self.server_identity } pub fn application_account(&self) -> Option<&str> { self.application_account.as_deref() } pub fn default_key_title(&self) -> &str { &self.default_key_title } pub fn default_key_fingerprint(&self) -> &str { &self.default_key_fingerprint } pub fn authentication_timeout_seconds(&self) -> u64 { self.authentication_timeout_seconds } pub fn biometric_unlock_enabled(&self) -> bool { self.biometric_unlock_enabled } pub fn appearance(&self) -> MobileAppearance { self.appearance } pub fn watch_state(&self) -> MobileWatchPreferenceState { self.watch_state } pub fn watch_title(&self) -> &str { &self.watch_title } pub fn watch_detail(&self) -> &str { &self.watch_detail } } #[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 } } pub struct MobileEntryPresentation { page: MobileEntryPage, totp: Option, cache_notice: Option, } impl MobileEntryPresentation { pub fn page(&self) -> &MobileEntryPage { &self.page } pub fn totp(&self) -> Option<&MobileTotpDetail> { self.totp.as_ref() } pub fn cache_notice(&self) -> Option<&str> { self.cache_notice.as_deref() } pub fn into_parts(self) -> (MobileEntryPage, Option, Option) { (self.page, self.totp, self.cache_notice) } } 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: MobileAuthenticationHandle, key: KeyInfo, } struct MobileAuthenticationStatus { biometric_unlock_enabled: bool, authentication_timeout: AuthenticationTimeout, appearance: MobileAppearance, git_identity: GitIdentity, active: Option, next_editor_id: u64, editors: BTreeMap, mutation_active: bool, repository_operation_active: bool, watch_shared_totp_entries: std::collections::BTreeSet, watch_snapshot: WatchSnapshotSender, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum MobileRepositoryOperation { Commit, Fetch, Pull, Push, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum MobileRepositoryOperationError { Busy, DirtyEditor, OpenEditor, Unavailable, } pub(crate) struct MobileRepositoryOperationGuard<'a> { authentication: &'a MobileAuthentication, } impl Drop for MobileRepositoryOperationGuard<'_> { fn drop(&mut self) { if let Ok(mut status) = self.authentication.status.lock() { status.repository_operation_active = false; } } } /// One process-wide mobile authentication lease shared by every tab and viewer. type MobileAuthenticationSession = AuthenticationSession, Arc>; type MobileAuthenticationHandle = AuthenticationHandle, Arc>; pub struct MobileAuthentication { config: Config, repository: Repository, keys: KeyStore, session: MobileAuthenticationSession, backend: Arc, status: Mutex, totp_discovery: Mutex<()>, } impl MobileAuthentication { pub(crate) fn matches_configuration( &self, config: &Config, ) -> Result { let status = self.status()?; if self.config.source() != config.source() || self.config.vault() != config.vault() || self.config.key_material() != config.key_material() || self.config.default_key() != config.default_key() || self.config.git_remotes() != config.git_remotes() || status.git_identity != *config.git_identity() || status.authentication_timeout != config.authentication_timeout() || status.biometric_unlock_enabled != config.biometric_unlock_enabled() { return Ok(false); } let current = Repository::open(config.vault()).map_err(entry_error)?; Ok(self .repository .navigation_revision(Path::new(""), false) .map_err(entry_error)? == current .navigation_revision(Path::new(""), false) .map_err(entry_error)?) } pub fn load() -> Result { let config = Config::load(None).map_err(|error| { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Configuration, "Authentication Is Unavailable", error.to_string(), ) })?; let backend = Arc::new(NativeSecretBackend::new().map_err(preference_secret_error)?); Self::with_platform( config, backend, Arc::new(SystemAuthenticationClock::default()), ) } /// OS mechanics are injected; configuration, protection, leases and storage stay here. pub fn with_platform( config: Config, backend: Arc, clock: Arc, ) -> Result { let repository = Repository::open(config.vault()).map_err(entry_error)?; let keys = KeyStore::load(config.key_material()).map_err(key_error)?; let session = MobileAuthenticationSession::with_clock( Arc::clone(&backend), SecretProtectionPolicy::current_biometry_for_openpgp(), config.authentication_timeout(), clock, ); let watch_snapshot = WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml")); Ok(Self { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), authentication_timeout: config.authentication_timeout(), appearance: config.mobile_appearance(), git_identity: config.git_identity().clone(), active: None, next_editor_id: 0, editors: BTreeMap::new(), mutation_active: false, repository_operation_active: false, watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), watch_snapshot, }), config, repository, keys, session, backend, totp_discovery: Mutex::new(()), }) } /// 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)?; self.unlock_ciphertext(&ciphertext, passphrase) } fn unlock_ciphertext( &self, ciphertext: &EncryptedEntry, passphrase: Option, ) -> Result { 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)?; self.status()?.editors.clear(); } 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 && let Err(error) = handle.persist_passphrase(key) { let _ = self.manual_lock(); return Err(MobileAuthenticationError::authentication(error)); } self.set_active(handle, key.clone())?; return self.state(); } Err(CryptoError::DecryptionFailed) => { self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; } Err(error) => { let _ = self.manual_lock(); return Err(key_error(error)); } } } Err(MobileAuthenticationError::new( MobileAuthenticationErrorKind::InvalidPassphrase, "Incorrect Passphrase", "The GPG key could not be unlocked.", )) } None if biometric_enabled => { let mut failure = None; for key in &candidates { let handle = match self.session.authenticate(key) { Ok(handle) => handle, Err(AuthenticationError::Cancelled) => { return Err(MobileAuthenticationError::authentication( AuthenticationError::Cancelled, )); } Err(error) => { failure = Some(MobileAuthenticationError::authentication(error)); 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(failure.unwrap_or_else(|| 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.", )), } } /// Explicit enrollment also works before the store contains a password entry. pub fn enroll_biometric_unlock( &self, passphrase: SecretBytes, ) -> Result { self.ensure_repository_idle()?; self.keys .validate_secret_passphrase(self.config.default_key().as_str(), Some(&passphrase)) .map_err(|error| match error { CryptoError::InvalidKeyPassphrase => MobileAuthenticationError::new( MobileAuthenticationErrorKind::InvalidPassphrase, "Incorrect Passphrase", "The GPG key could not be unlocked.", ), error => key_error(error), })?; let fingerprint = self .keys .resolve(self.config.default_key().as_str()) .map_err(key_error)?; let key = self .keys .infos() .find(|key| key.fingerprint() == fingerprint.fingerprint()) .ok_or_else(|| key_error(CryptoError::MissingSecretKey))?; let handle = self .session .authenticate_with_passphrase(&key, passphrase) .map_err(MobileAuthenticationError::authentication)?; if let Err(error) = self.persist_biometric_unlock(&handle, &key) { let _ = self.manual_lock(); return Err(error); } self.set_active(handle, key)?; self.state() } /// Restore the configured key's lease for an explicit protected-access request. pub fn restore_biometric_lease( &self, ) -> Result { self.ensure_repository_idle()?; let state = self.state()?; if state.unlocked() { return Ok(state); } if !state.biometric_unlock_enabled() { return Err(MobileAuthenticationError::passphrase_required( "Enter the GPG key passphrase to continue.", )); } let fingerprint = self .keys .resolve(self.config.default_key().as_str()) .map_err(key_error)?; let key = self .keys .infos() .find(|key| key.fingerprint() == fingerprint.fingerprint()) .ok_or_else(|| key_error(CryptoError::MissingSecretKey))?; let mut handle = self .session .authenticate(&key) .map_err(MobileAuthenticationError::authentication)?; let passphrase = handle.secret_for(&key).map_err(|_| locked_error())?; if self .keys .validate_secret_passphrase(key.fingerprint().as_str(), Some(&passphrase)) .is_err() { let _ = self.session.delete_key_passphrase(&key); let _ = self.manual_lock(); return Err(MobileAuthenticationError::passphrase_required( "The protected passphrase is unusable. Enter its GPG passphrase to recover.", )); } self.set_active(handle, key)?; self.state() } fn persist_biometric_unlock( &self, handle: &MobileAuthenticationHandle, key: &KeyInfo, ) -> Result<(), MobileAuthenticationError> { handle .persist_passphrase(key) .map_err(MobileAuthenticationError::authentication)?; 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; Ok(()) } 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.", ) })?; let handle = active.handle.clone(); let key = active.key.clone(); drop(status); self.persist_biometric_unlock(&handle, &key)?; } 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; status.editors.clear(); } 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; status.editors.clear(); } 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 git_identity(&self) -> Result { Ok(self.status()?.git_identity.clone()) } pub fn mobile_appearance(&self) -> Result { Ok(self.status()?.appearance) } pub fn set_git_identity( &self, name: String, email: String, ) -> Result { let identity = GitIdentity::new(name, email).map_err(|_| { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Configuration, "Git Identity Is Invalid", "Enter a non-empty name and email without line breaks or angle brackets.", ) })?; self.config .update_git_identity(&identity) .map_err(config_error)?; self.status()?.git_identity = identity.clone(); Ok(identity) } pub fn preferences( &self, watch_supported: bool, watch_paired: bool, watch_app_installed: bool, ) -> Result { let remote = self .config .git_remotes() .iter() .find(|remote| remote.name().as_str() == "origin") .or_else(|| self.config.git_remotes().first()); let handle = self .keys .resolve(self.config.default_key().as_str()) .map_err(key_error)?; let key = self .keys .infos() .find(|key| key.fingerprint() == handle.fingerprint()) .ok_or_else(|| config_detail("The default GPG key is unavailable."))?; let status = self.status()?; let selected = status.watch_shared_totp_entries.len(); let snapshot = status.watch_snapshot.status(); let (watch_state, watch_title, watch_detail) = watch_preference( watch_supported, watch_paired, watch_app_installed, selected, &snapshot, ); let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs(); let biometric_unlock_enabled = status.biometric_unlock_enabled; let appearance = status.appearance; drop(status); let repository_title = remote.map_or_else( || "Local Password Store".to_owned(), remote_repository_title, ); Ok(MobilePreferences { sync_configured: remote.is_some(), repository_title, repository_url: remote.map_or_else( || "Stored and versioned on this device".to_owned(), |remote| remote.url().to_string(), ), server_title: remote .and_then(|remote| { remote .endpoint() .as_https() .and_then(url::Url::host_str) .or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host())) }) .unwrap_or("Git Sync Not Configured") .to_owned(), server_identity: remote.map_or_else( || "Add an HTTPS remote when you want to sync".to_owned(), |remote| { remote.https_credentials().map_or_else( || "SSH identity and host trust".to_owned(), |(server_id, _)| server_id.as_str().to_owned(), ) }, ), application_account: remote .map(|remote| application_account(remote, Arc::clone(&self.backend))) .transpose()? .flatten(), default_key_title: key .user_ids() .first() .cloned() .unwrap_or_else(|| self.config.default_key().to_string()), default_key_fingerprint: key.fingerprint().to_string(), authentication_timeout_seconds, biometric_unlock_enabled, appearance, watch_state, watch_title, watch_detail, }) } pub fn set_authentication_timeout( &self, seconds: u64, ) -> Result<(), MobileAuthenticationError> { let timeout = AuthenticationTimeout::new(Duration::from_secs(seconds)) .map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?; let previous = self.status()?.authentication_timeout; self.session .set_timeout(timeout) .map_err(MobileAuthenticationError::authentication)?; if let Err(error) = self.config.update_authentication_timeout(timeout) { let _ = self.session.set_timeout(previous); return Err(config_error(error)); } self.status()?.authentication_timeout = timeout; Ok(()) } pub fn set_mobile_appearance( &self, appearance: MobileAppearance, ) -> Result<(), MobileAuthenticationError> { self.config .update_mobile_appearance(appearance) .map_err(config_error)?; self.status()?.appearance = appearance; Ok(()) } pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationError> { let remote = self .config .git_remotes() .iter() .find(|remote| remote.name().as_str() == "origin") .or_else(|| self.config.git_remotes().first()) .ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?; let store = preference_secret_store(Arc::clone(&self.backend)); store.unlock().map_err(preference_secret_error)?; let (server_id, application_id) = remote.https_credentials().ok_or_else(|| { config_detail("The configured Git remote does not use HTTPS credentials.") })?; let result = store.delete_https_git_credential(server_id, application_id); let lock_result = store.lock(); if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) { return Err(preference_secret_error(result.expect_err("checked error"))); } lock_result.map_err(preference_secret_error) } 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) } /// Open only the entry selected from a still-current navigation page. pub fn navigation_entry( &self, directory: &str, row_id: &str, revision: &str, passphrase: Option, unix_seconds: u64, ) -> Result { let validate = || { let page = crate::mobile_passwords::navigation::navigate_repository( &self.repository, directory, Some(row_id), Some(revision), false, ) .map_err(|_| { entry_detail( "Selection Is Unavailable", "Refresh the folder and select the entry again.", ) })?; page.selected .filter(|row| { row.row.kind() == crate::mobile_passwords::MobilePasswordRowKind::Entry }) .ok_or_else(|| { entry_detail( "Selection Changed", "Refresh the folder and select the entry again.", ) }) }; let selected = validate()?; self.unlock_entry(selected.row.path(), passphrase)?; let presentation = self.entry_page(selected.row.path(), unix_seconds)?; if let Err(error) = validate() { self.manual_lock()?; return Err(error); } Ok(presentation) } pub fn entry_page( &self, path: &str, unix_seconds: u64, ) -> Result { let document = self.open_active_document(path)?; let ciphertext = document .original_ciphertext() .ok_or_else(|| entry_detail("Password Entry Is Unavailable", "entry does not exist"))?; let shared = self.status()?.watch_shared_totp_entries.clone(); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let reconciliation = MobileTotpService::new(&self.repository, &self.keys) .reconcile_document( &document, ciphertext, Some(unix_seconds), &shared, &cache_path, ) .map_err(totp_error)?; let (mut totp, cache_notice) = reconciliation.into_parts(); if let Some(detail) = &mut totp { detail.set_watch(self.watch_snapshot_status()?); } Ok(MobileEntryPresentation { page: MobileEntryPage::from_document(&document), totp, cache_notice, }) } 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) } /// Field IDs are scoped to the exact document that the viewer presented. pub fn reveal_presented_entry_field( &self, path: &str, field_id: u64, revision: &str, ) -> Result { let document = self.open_presented_document(path, revision)?; field_value(&document, field_id).map_err(value_error) } pub fn copy_presented_entry_field( &self, path: &str, field_id: u64, revision: &str, ) -> Result { Ok(MobileEntryCopy { value: self.reveal_presented_entry_field(path, field_id, revision)?, timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), }) } pub fn refresh_entry_presentation( &self, path: &str, revision: &str, unix_seconds: u64, ) -> Result { let presentation = self.entry_page(path, unix_seconds)?; self.check_document_revision(presentation.page().revision(), revision)?; Ok(presentation) } fn open_presented_document( &self, path: &str, revision: &str, ) -> Result { let document = self.open_active_document(path)?; self.check_document_revision(&document_revision(&document), revision)?; Ok(document) } fn check_document_revision( &self, current: &str, expected: &str, ) -> Result<(), MobileAuthenticationError> { if current != expected { self.manual_lock()?; return Err(MobileAuthenticationError::new( MobileAuthenticationErrorKind::Conflict, "Entry Changed", "Refresh and unlock the entry again before using its fields.", )); } Ok(()) } pub fn copy_presented_totp_code( &self, path: &str, revision: &str, unix_seconds: u64, ) -> Result { let presentation = self.refresh_entry_presentation(path, revision, unix_seconds)?; let detail = presentation.totp().ok_or_else(|| { entry_detail( "TOTP Code Is Unavailable", "The entry has no current TOTP code.", ) })?; Ok(MobileEntryCopy { value: String::from_utf8(detail.code().expose().to_vec()) .map_err(|_| entry_detail("TOTP Code Is Unavailable", "The code is not UTF-8."))?, timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), }) } pub fn prepare_presented_entry_mutation( &self, path: &str, revision: &str, action: MobileMutationAction, ) -> Result { self.open_presented_document(path, revision)?; let plan = self.prepare_entry_mutation(path, action)?; self.open_presented_document(path, revision)?; Ok(plan) } 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 prepare_entry_mutation( &self, path: &str, action: MobileMutationAction, ) -> Result { let (has_open_editor, has_dirty_editor) = self.editor_state(path)?; MobileMutationService::new(&self.repository) .prepare(path, action, has_open_editor, has_dirty_editor) .map_err(mutation_error) } pub fn perform_entry_mutation( &self, request: MobileMutationRequest, ) -> Result { self.ensure_active()?; self.reserve_entry_mutation()?; let result = self.perform_reserved_entry_mutation(request); self.release_entry_mutation(); result } fn perform_reserved_entry_mutation( &self, request: MobileMutationRequest, ) -> Result { let (handle, key, identity) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; ( active.handle.clone(), active.key.clone(), status.git_identity.clone(), ) }; let mut committer = AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity) .map_err(git_mutation_error)?; let has_open_editor = self.editor_state(&request.source)?.0; let editors = if has_open_editor && request.discard_editor { self.take_entry_editors(&request.source)? } else { Vec::new() }; let mut provider = KeyOnlyProvider::new(handle, &key); let result = MobileMutationService::new(&self.repository).perform( &request, has_open_editor && editors.is_empty(), &self.keys, &mut provider, &mut committer, ); match result { Ok(outcome) => Ok(outcome), Err(error) => { self.restore_entry_editors(editors)?; Err(mutation_error(error)) } } } pub fn unlock_totp( &self, passphrase: Option, ) -> Result { let path = self .repository .snapshot() .map_err(entry_error)? .entries() .next() .map(|entry| entry.path().clone()) .ok_or_else(|| entry_detail("TOTP Is Unavailable", "the password store is empty"))?; let ciphertext = self.repository.read_entry(&path).map_err(entry_error)?; self.unlock_ciphertext(&ciphertext, passphrase) } pub fn totp_page( &self, operation: &MobileTotpOperation, ) -> Result { self.ensure_repository_idle()?; self.ensure_active()?; let _discovery = self.totp_discovery.lock().map_err(|_| { entry_detail( "TOTP Discovery Is Unavailable", "the TOTP discovery lock is unavailable", ) })?; let (handle, key, shared) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; ( active.handle.clone(), active.key.clone(), status.watch_shared_totp_entries.clone(), ) }; let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let mut provider = KeyOnlyProvider::new(handle, &key); let mut page = MobileTotpService::new(&self.repository, &self.keys) .discover(&shared, &mut provider, &cache_path, operation) .map_err(totp_error)?; page.set_watch(self.watch_snapshot_status()?); Ok(page) } pub fn cached_totp_page(&self) -> Result, MobileAuthenticationError> { self.search_cached_totp_page("") } pub fn search_cached_totp_page( &self, query: &str, ) -> Result, MobileAuthenticationError> { self.ensure_repository_idle()?; let shared = self.status()?.watch_shared_totp_entries.clone(); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page( &shared, &cache_path, query, ); if let Some(page) = &mut page { page.set_watch(self.watch_snapshot_status()?); } Ok(page) } pub fn totp_detail( &self, path: &str, unix_seconds: u64, ) -> Result { self.ensure_active()?; let (handle, key, shared) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; ( active.handle.clone(), active.key.clone(), status.watch_shared_totp_entries.clone(), ) }; let mut provider = KeyOnlyProvider::new(handle, &key); let mut detail = MobileTotpService::new(&self.repository, &self.keys) .detail(path, unix_seconds, &shared, &mut provider) .map_err(totp_error)?; detail.set_watch(self.watch_snapshot_status()?); Ok(detail) } pub fn copy_totp_code( &self, path: &str, unix_seconds: u64, ) -> Result { let detail = self.totp_detail(path, unix_seconds)?; let value = String::from_utf8(detail.code().expose().to_vec()) .map_err(|_| entry_detail("TOTP Code Is Unavailable", "the code is not UTF-8"))?; Ok(MobileEntryCopy { value, timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), }) } pub fn set_totp_watch_shared( &self, path: &str, shared: bool, unix_seconds: u64, ) -> Result { self.totp_detail(path, unix_seconds)?; let entry = EntryPath::parse(path).map_err(entry_error)?; let mut selected = self.status()?.watch_shared_totp_entries.clone(); if shared { selected.insert(entry); } else { selected.remove(&entry); } self.config .update_watch_shared_totp_entries(&selected) .map_err(config_error)?; self.status()?.watch_shared_totp_entries = selected; self.totp_detail(path, unix_seconds) } pub fn watch_snapshot_status( &self, ) -> Result { Ok(self.status()?.watch_snapshot.status()) } pub fn prepare_watch_snapshot( &self, platform_pairing_identity: &str, ) -> Result { self.ensure_active()?; let (handle, key, selected) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; ( active.handle.clone(), active.key.clone(), status.watch_shared_totp_entries.clone(), ) }; let mut provider = KeyOnlyProvider::new(handle, &key); let selection = MobileTotpService::new(&self.repository, &self.keys) .watch_snapshot_selection(&selected, &mut provider) .map_err(totp_error)?; let (entries, retained) = selection.into_parts(); let mut status = self.status()?; if status.watch_shared_totp_entries != selected { return Err(entry_detail( "Apple Watch Snapshot Changed", "the selected TOTP entries changed while the snapshot was being prepared", )); } if retained != selected { drop(status); self.config .update_watch_shared_totp_entries(&retained) .map_err(config_error)?; status = self.status()?; status.watch_shared_totp_entries = retained; } status .watch_snapshot .prepare(platform_pairing_identity, entries) .map_err(watch_error) } pub fn set_watch_snapshot_unavailable( &self, unpaired: bool, detail: String, ) -> Result { let mut status = self.status()?; status .watch_snapshot .unavailable(unpaired, detail) .map_err(watch_error)?; Ok(status.watch_snapshot.status()) } pub fn fail_watch_snapshot( &self, revision: u64, detail: String, ) -> Result { let mut status = self.status()?; status.watch_snapshot.failed(revision, detail); Ok(status.watch_snapshot.status()) } pub fn acknowledge_watch_snapshot( &self, receipt: &[u8], ) -> Result { self.status()? .watch_snapshot .acknowledge(receipt) .map_err(watch_error) } pub fn replace_entry_field( &self, path: &str, field: u64, value: String, ) -> Result { self.ensure_repository_idle()?; let mut document = self.open_active_document(path)?; self.save_replaced_entry_field(&mut document, field, value) } pub fn replace_presented_entry_field( &self, path: &str, field_id: u64, revision: &str, value: String, ) -> Result { self.ensure_repository_idle()?; let mut document = self.open_presented_document(path, revision)?; self.save_replaced_entry_field(&mut document, field_id, value) } fn save_replaced_entry_field( &self, document: &mut EntryDocument, field_id: u64, value: String, ) -> Result { document .replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes()) .map_err(document_error)?; let identity = self.git_identity()?; let mut committer = AutomaticEntryCommitter::for_entry( &self.repository, &document.path().to_string(), identity, ) .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)?; self.reconcile_saved_document(document); Ok(MobileEntryPage::from_document(document)) } pub fn begin_entry_editor( &self, path: &str, ) -> Result { let document = self.open_active_document(path)?; self.store_editor(MobileEntryDraft::new(document, false)) } pub fn begin_create_entry( &self, directory: &str, name: &str, passphrase: Option, ) -> Result { let directory = DirectoryPath::parse(directory).map_err(entry_error)?; let leaf = EntryPath::parse(name).map_err(entry_error)?; if leaf .as_path() .parent() .is_some_and(|parent| !parent.as_os_str().is_empty()) { return Err(entry_detail( "Entry Name Is Invalid", "enter a name without a folder separator", )); } let path = EntryPath::parse(directory.as_path().join(leaf.as_path())).map_err(entry_error)?; let path_text = path.to_string(); if VaultWriter::new(&self.repository, &self.keys) .entry_exists(&path_text) .map_err(|error| entry_detail("Password Entry Is Unavailable", error))? { return Err(entry_detail( "Password Already Exists", "Choose a different entry name.", )); } let recipients = RecipientPolicyManager::new(&self.repository, &self.keys) .resolve_for_entry(&path, None) .map_err(|error| entry_detail("Recipients Are Unavailable", error))?; let probe = self .keys .encrypt( SecretBytes::new(b"IronStorage mobile entry creation".to_vec()), recipients.recipients(), ) .map_err(key_error)?; self.unlock_ciphertext(&probe, passphrase)?; let mut document = self.open_active_document(&path_text)?; document .add( 0, crate::document::EntryFieldDraft::line(Vec::new()).map_err(document_error)?, ) .map_err(document_error)?; self.store_editor(MobileEntryDraft::new(document, true)) } pub fn create_directory( &self, parent: &str, name: &str, ) -> Result<(), MobileAuthenticationError> { self.ensure_repository_idle()?; let parent = DirectoryPath::parse(parent).map_err(entry_error)?; let leaf = DirectoryPath::parse(name).map_err(entry_error)?; if leaf .as_path() .parent() .is_some_and(|parent| !parent.as_os_str().is_empty()) { return Err(entry_detail( "Folder Name Is Invalid", "enter a name without a folder separator", )); } let directory = DirectoryPath::parse(parent.as_path().join(leaf.as_path())).map_err(entry_error)?; if self .repository .snapshot() .map_err(entry_error)? .directories() .any(|record| record.path() == &directory) { return Err(entry_detail( "Folder Already Exists", "Choose a different folder name.", )); } self.repository .ensure_directory(&directory) .map_err(|error| entry_detail("Folder Could Not Be Created", error))?; Ok(()) } pub fn entry_editor( &self, editor: u64, ) -> Result { self.ensure_active()?; self.status()? .editors .get(&editor) .map(MobileEntryDraft::page) .ok_or_else(editor_missing) } pub fn update_entry_editor( &self, editor: u64, fields: Vec, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft.apply(fields).map_err(editor_error)?; Ok(draft.page()) } pub fn add_entry_editor_field( &self, editor: u64, kind: MobileEntryEditorFieldKind, name: Option, value: String, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft.add(kind, name, value).map_err(editor_error)?; Ok(draft.page()) } pub fn add_entry_editor_otp_image( &self, editor: u64, image: SecretBytes, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft.add_otp_image(image).map_err(editor_error)?; Ok(draft.page()) } pub fn remove_entry_editor_field( &self, editor: u64, field: u64, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft.remove(field).map_err(editor_error)?; Ok(draft.page()) } pub fn reorder_entry_editor_field( &self, editor: u64, field: u64, index: usize, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft.reorder(field, index).map_err(editor_error)?; Ok(draft.page()) } pub fn generate_entry_editor_password( &self, editor: u64, length: Option, no_symbols: bool, ) -> Result { self.ensure_active()?; let mut status = self.status()?; let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?; draft .generate_password(length, no_symbols) .map_err(editor_error)?; Ok(draft.page()) } pub fn save_entry_editor( &self, editor: u64, fields: Vec, ) -> Result { self.ensure_active()?; self.ensure_repository_idle()?; let mut draft = self .status()? .editors .remove(&editor) .ok_or_else(editor_missing)?; if let Err(error) = draft.apply(fields) { self.restore_editor(editor, draft)?; return Err(editor_error(error)); } let path = draft.document().path().to_string(); let identity = self.git_identity()?; let mut committer = match AutomaticEntryCommitter::for_entry(&self.repository, &path, identity) { Ok(committer) => committer, Err(error) => { self.restore_editor(editor, draft)?; return Err(entry_detail("Password Entry Could Not Be Saved", error)); } }; if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys) .save_recoverable(draft.document(), None, &mut committer) { self.restore_editor(editor, draft)?; return Err(document_error(error)); } self.reconcile_saved_document(draft.document()); Ok(MobileEntryPage::from_document(draft.document())) } pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> { self.status()?.editors.remove(&editor); Ok(()) } pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { self.session .manual_lock() .map_err(MobileAuthenticationError::authentication)?; let mut status = self.status()?; status.active = None; status.editors.clear(); Ok(()) } pub fn lifecycle( &self, event: crate::mobile_platform::MobileLifecycleEvent, ) -> Result { use crate::mobile_platform::MobileLifecycleEvent; match event { MobileLifecycleEvent::Foreground => {} MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => { self.manual_lock()? } MobileLifecycleEvent::UserActivity => { if self.state()?.unlocked() { self.touch_user_activity()?; } } MobileLifecycleEvent::Cancel => self.cancel()?, } self.state() } pub fn cancel(&self) -> Result<(), MobileAuthenticationError> { self.session .cancel() .map_err(MobileAuthenticationError::authentication)?; let mut status = self.status()?; status.active = None; status.editors.clear(); 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: MobileAuthenticationHandle, key: KeyInfo, ) -> Result<(), MobileAuthenticationError> { self.status()?.active = Some(ActiveMobileLease { handle, key }); Ok(()) } fn ensure_active(&self) -> Result<(), MobileAuthenticationError> { let handle = self .status()? .active .as_ref() .map(|active| active.handle.clone()) .ok_or_else(locked_error)?; if let Err(error) = handle.ensure_active() { let mut status = self.status()?; status.active = None; status.editors.clear(); return Err(MobileAuthenticationError::authentication(error)); } Ok(()) } fn store_editor( &self, draft: MobileEntryDraft, ) -> Result { let mut status = self.status()?; if status.mutation_active || status.repository_operation_active { return Err(entry_detail( "Password Action In Progress", "wait for the current password-store action to finish", )); } let id = status.next_editor_id; status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| { entry_detail( "Entry Editor Is Unavailable", "entry editor identifiers are exhausted", ) })?; let page = draft.page(); status.editors.insert(id, draft); Ok(MobileEntryEditorSession::new(id, page)) } fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> { let mut status = self.status()?; // ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it. if status.mutation_active || status.repository_operation_active { return Err(MobileAuthenticationError::new( MobileAuthenticationErrorKind::Conflict, "Password Action In Progress", "Wait for the current move, copy, or delete action to finish.", )); } status.mutation_active = true; Ok(()) } pub(crate) fn reserve_repository_operation( &self, operation: MobileRepositoryOperation, ) -> Result, MobileRepositoryOperationError> { let mut status = self .status .lock() .map_err(|_| MobileRepositoryOperationError::Unavailable)?; let dirty_editor = status .editors .values() .any(|draft| draft.document().is_modified()); if let Some(error) = repository_operation_conflict( operation, status.mutation_active || status.repository_operation_active, !status.editors.is_empty(), dirty_editor, ) { return Err(error); } status.repository_operation_active = true; drop(status); Ok(MobileRepositoryOperationGuard { authentication: self, }) } fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> { if self.status()?.repository_operation_active { Err(entry_detail( "Repository Action In Progress", "wait for the current commit, fetch, pull, or push action to finish", )) } else { Ok(()) } } fn release_entry_mutation(&self) { if let Ok(mut status) = self.status.lock() { status.mutation_active = false; } } fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> { let status = self.status()?; let mut matching = status .editors .values() .filter(|draft| draft.document().path().to_string() == path); let Some(first) = matching.next() else { return Ok((false, false)); }; Ok(( true, first.document().is_modified() || matching.any(|draft| draft.document().is_modified()), )) } fn take_entry_editors( &self, path: &str, ) -> Result, MobileAuthenticationError> { let mut status = self.status()?; let ids = status .editors .iter() .filter(|(_, draft)| draft.document().path().to_string() == path) .map(|(id, _)| *id) .collect::>(); Ok(ids .into_iter() .filter_map(|id| status.editors.remove(&id).map(|draft| (id, draft))) .collect()) } fn restore_entry_editors( &self, editors: Vec<(u64, MobileEntryDraft)>, ) -> Result<(), MobileAuthenticationError> { self.status()?.editors.extend(editors); Ok(()) } fn restore_editor( &self, editor: u64, draft: MobileEntryDraft, ) -> Result<(), MobileAuthenticationError> { self.status()?.editors.insert(editor, draft); 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) } fn reconcile_saved_document(&self, document: &EntryDocument) { let Ok(ciphertext) = self.repository.read_entry(document.path()) else { return; }; let Ok(status) = self.status() else { return; }; let shared = status.watch_shared_totp_entries.clone(); drop(status); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let _ = MobileTotpService::new(&self.repository, &self.keys).reconcile_document( document, &ciphertext, None, &shared, &cache_path, ); } } fn repository_operation_conflict( operation: MobileRepositoryOperation, busy: bool, open_editor: bool, dirty_editor: bool, ) -> Option { if busy { Some(MobileRepositoryOperationError::Busy) } else if operation == MobileRepositoryOperation::Pull && open_editor { Some(if dirty_editor { MobileRepositoryOperationError::DirtyEditor } else { MobileRepositoryOperationError::OpenEditor }) } else if operation == MobileRepositoryOperation::Push && dirty_editor { Some(MobileRepositoryOperationError::DirtyEditor) } else { None } } struct KeyOnlyProvider<'a> { handle: MobileAuthenticationHandle, fingerprint: &'a str, } impl<'a> KeyOnlyProvider<'a> { fn new(handle: MobileAuthenticationHandle, 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 config_detail(detail: &str) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Configuration, "Preference Is Unavailable", detail, ) } fn preference_secret_store( backend: Arc, ) -> SecretStore> { SecretStore::new( backend, SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ) } fn application_account( remote: &GitRemote, backend: Arc, ) -> Result, MobileAuthenticationError> { let Some((server_id, application_id)) = remote.https_credentials() else { return Ok(None); }; let store = preference_secret_store(backend); store.unlock().map_err(preference_secret_error)?; let result = store.https_git_credential_account(server_id, application_id); let lock_result = store.lock(); let account = match result { Ok(account) => Some(account), Err(SecretStoreError::Missing) => None, Err(error) => return Err(preference_secret_error(error)), }; lock_result .map_err(preference_secret_error) .map(|()| account) } fn remote_repository_title(remote: &GitRemote) -> String { let path = remote.endpoint().as_https().map_or_else( || { remote .endpoint() .as_ssh() .map(|ssh| ssh.path().as_str()) .unwrap_or_default() }, |url| url.path(), ); path.trim_end_matches('/') .rsplit('/') .next() .filter(|name| !name.is_empty()) .unwrap_or("Password Store") .trim_end_matches(".git") .to_owned() } fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::SecureStorage, "Application Token Is Unavailable", match error { SecretStoreError::Denied => "Access to protected token storage was denied.".to_owned(), SecretStoreError::Cancelled => { "Access to protected token storage was cancelled.".to_owned() } _ => "Protected application-token storage is unavailable.".to_owned(), }, ) } fn watch_preference( supported: bool, paired: bool, app_installed: bool, selected: usize, snapshot: &MobileWatchSnapshotStatus, ) -> (MobileWatchPreferenceState, String, String) { if !supported { return ( MobileWatchPreferenceState::Unsupported, "Unavailable".to_owned(), "Apple Watch connectivity is unavailable on this device.".to_owned(), ); } if !paired { return ( MobileWatchPreferenceState::NotPaired, "Not Paired".to_owned(), "Pair an Apple Watch in the Watch app to enable synchronization.".to_owned(), ); } if !app_installed { return ( MobileWatchPreferenceState::AppNotInstalled, "Watch App Not Installed".to_owned(), "Install the IronStorage companion on the paired Apple Watch.".to_owned(), ); } match snapshot.state() { MobileWatchSnapshotState::Pending => ( MobileWatchPreferenceState::Pending, "Synchronization Pending".to_owned(), snapshot.detail().to_owned(), ), MobileWatchSnapshotState::Delivered => ( MobileWatchPreferenceState::Delivered, "Delivered".to_owned(), snapshot.detail().to_owned(), ), MobileWatchSnapshotState::Current => ( MobileWatchPreferenceState::Current, "Current".to_owned(), snapshot.detail().to_owned(), ), MobileWatchSnapshotState::Failed => ( MobileWatchPreferenceState::Failed, "Synchronization Failed".to_owned(), snapshot.detail().to_owned(), ), MobileWatchSnapshotState::Unavailable | MobileWatchSnapshotState::Unpaired => { if selected == 0 { ( MobileWatchPreferenceState::Ready, "Ready".to_owned(), "No TOTP codes are selected for Apple Watch.".to_owned(), ) } else { ( MobileWatchPreferenceState::Pending, "Synchronization Pending".to_owned(), format!( "{selected} selected TOTP {} pending Apple Watch synchronization.", if selected == 1 { "code is" } else { "codes are" } ), ) } } } } 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 { let kind = if matches!( &error, DocumentError::Write(WriteError::ConcurrentModification { .. }) ) { MobileAuthenticationErrorKind::Conflict } else { MobileAuthenticationErrorKind::Entry }; MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string()) } fn mutation_error(error: MobileMutationError) -> MobileAuthenticationError { MobileAuthenticationError::new( if error.is_conflict() { MobileAuthenticationErrorKind::Conflict } else { MobileAuthenticationErrorKind::Entry }, error.title(), error.to_string(), ) } fn git_mutation_error(error: GitError) -> MobileAuthenticationError { let conflict = matches!( &error, GitError::DirtyWorktree | GitError::MergeConflicts { .. } ); MobileAuthenticationError::new( if conflict { MobileAuthenticationErrorKind::Conflict } else { MobileAuthenticationErrorKind::Entry }, if conflict { "Password Action Blocked by Git" } else { "Password Action Failed" }, error.to_string(), ) } fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError { entry_detail("Field Value Is Unavailable", error) } fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError { entry_detail("Entry Draft Is Invalid", error) } fn totp_error(error: MobileTotpError) -> MobileAuthenticationError { match error { MobileTotpError::Otp(OtpError::Cancelled) => MobileAuthenticationError::new( MobileAuthenticationErrorKind::Cancelled, "TOTP Discovery Cancelled", "TOTP discovery was cancelled", ), MobileTotpError::ConcurrentModification => MobileAuthenticationError::new( MobileAuthenticationErrorKind::Conflict, "TOTP Discovery Needs Refreshing", "the password store changed while TOTP discovery was in progress", ), error => entry_detail("TOTP Is Unavailable", error), } } fn watch_error(error: WatchSnapshotError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, "Apple Watch Synchronization Failed", error.to_string(), ) } fn editor_missing() -> MobileAuthenticationError { entry_detail( "Entry Draft Is Unavailable", "the entry draft was discarded or locked", ) } fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, title, error.to_string(), ) } #[cfg(test)] mod tests { use super::{ MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState, MobileWatchSnapshotState, MobileWatchSnapshotStatus, repository_operation_conflict, watch_preference, }; #[test] fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { use crate::authentication::SystemAuthenticationClock; use std::sync::Arc; let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture(); let authentication = private .authentication(backend, Arc::new(SystemAuthenticationClock::default())) .unwrap(); assert!( crate::mobile_passwords::navigation::navigate_repository( &authentication.repository, "", None, None, false ) .unwrap() .rows .is_empty() ); assert!( authentication .enroll_biometric_unlock(crate::repository::SecretBytes::new( b"fixture-alice-passphrase".to_vec() )) .unwrap() .biometric_unlock_enabled() ); authentication.manual_lock().unwrap(); assert!(authentication.restore_biometric_lease().unwrap().unlocked()); authentication.set_biometric_unlock(false).unwrap(); assert!(authentication.restore_biometric_lease().is_err()); } #[test] fn repository_actions_protect_editors_and_serialize_mutations() { assert_eq!( repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, false), Some(MobileRepositoryOperationError::OpenEditor) ); assert_eq!( repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, true), Some(MobileRepositoryOperationError::DirtyEditor) ); assert_eq!( repository_operation_conflict(MobileRepositoryOperation::Push, false, true, true), Some(MobileRepositoryOperationError::DirtyEditor) ); assert_eq!( repository_operation_conflict(MobileRepositoryOperation::Fetch, false, true, true), None ); assert_eq!( repository_operation_conflict(MobileRepositoryOperation::Commit, true, false, false), Some(MobileRepositoryOperationError::Busy) ); } #[test] fn watch_preferences_render_platform_and_selection_state() { let unavailable = MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Unavailable, revision: None, detail: "not started".to_owned(), }; assert_eq!( watch_preference(true, false, false, 2, &unavailable).0, MobileWatchPreferenceState::NotPaired ); assert_eq!( watch_preference(true, true, true, 0, &unavailable).0, MobileWatchPreferenceState::Ready ); let pending = watch_preference(true, true, true, 2, &unavailable); assert_eq!(pending.0, MobileWatchPreferenceState::Pending); assert!(pending.2.contains("2 selected TOTP codes")); } }