Implement iPhone and Apple Watch preferences (#53)
This commit is contained in:
1 parent
81886756a2
commit
affdb55519
15 files changed
+1409
-60
No files matched your search
@@ -1,12 +1,13 @@
|
||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex};
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
|
||||
|
||||
use crate::{
|
||||
authentication::{
|
||||
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
|
||||
NativeAuthenticationSession,
|
||||
},
|
||||
config::{Config, ConfigError},
|
||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||
@@ -27,7 +28,9 @@ use crate::{
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
},
|
||||
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
},
|
||||
write::{VaultWriter, WriteError},
|
||||
};
|
||||
|
||||
@@ -129,6 +132,86 @@ pub struct MobileAuthenticationState {
|
||||
remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileWatchPreferenceState {
|
||||
Unsupported,
|
||||
NotPaired,
|
||||
AppNotInstalled,
|
||||
Ready,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobilePreferences {
|
||||
repository_title: String,
|
||||
repository_url: String,
|
||||
server_title: String,
|
||||
server_identity: String,
|
||||
application_account: Option<String>,
|
||||
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 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,
|
||||
@@ -190,6 +273,8 @@ struct ActiveMobileLease {
|
||||
|
||||
struct MobileAuthenticationStatus {
|
||||
biometric_unlock_enabled: bool,
|
||||
authentication_timeout: AuthenticationTimeout,
|
||||
appearance: MobileAppearance,
|
||||
git_identity: GitIdentity,
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
@@ -256,6 +341,8 @@ impl MobileAuthentication {
|
||||
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,
|
||||
@@ -430,6 +517,10 @@ impl MobileAuthentication {
|
||||
Ok(self.status()?.git_identity.clone())
|
||||
}
|
||||
|
||||
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationError> {
|
||||
Ok(self.status()?.appearance)
|
||||
}
|
||||
|
||||
pub fn set_git_identity(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -449,6 +540,112 @@ impl MobileAuthentication {
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn preferences(
|
||||
&self,
|
||||
watch_supported: bool,
|
||||
watch_paired: bool,
|
||||
watch_app_installed: bool,
|
||||
) -> Result<MobilePreferences, 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 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 (watch_state, watch_title, watch_detail) =
|
||||
watch_preference(watch_supported, watch_paired, watch_app_installed, selected);
|
||||
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 server_title = remote.url().host_str().unwrap_or("HTTPS server").to_owned();
|
||||
let repository_title = remote
|
||||
.url()
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned();
|
||||
Ok(MobilePreferences {
|
||||
repository_title,
|
||||
repository_url: remote.url().to_string(),
|
||||
server_title,
|
||||
server_identity: remote.server_id().as_str().to_owned(),
|
||||
application_account: application_account(remote)?,
|
||||
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()?;
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let result = store.delete_https_git_credential(remote.server_id(), remote.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(|| {
|
||||
@@ -1183,6 +1380,99 @@ fn config_error(error: ConfigError) -> MobileAuthenticationError {
|
||||
)
|
||||
}
|
||||
|
||||
fn config_detail(detail: &str) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Configuration,
|
||||
"Preference Is Unavailable",
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
|
||||
NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.map_err(preference_secret_error)
|
||||
}
|
||||
|
||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
let store = preference_secret_store()?;
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let result = store.https_git_credential_account(remote.server_id(), remote.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 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,
|
||||
) -> (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(),
|
||||
);
|
||||
}
|
||||
if selected == 0 {
|
||||
return (
|
||||
MobileWatchPreferenceState::Ready,
|
||||
"Ready".to_owned(),
|
||||
"No TOTP codes are selected for Apple Watch.".to_owned(),
|
||||
);
|
||||
}
|
||||
(
|
||||
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,
|
||||
@@ -1293,7 +1583,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict,
|
||||
MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
|
||||
repository_operation_conflict, watch_preference,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1319,4 +1610,19 @@ mod tests {
|
||||
Some(MobileRepositoryOperationError::Busy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_preferences_render_platform_and_selection_state() {
|
||||
assert_eq!(
|
||||
watch_preference(true, false, false, 2).0,
|
||||
MobileWatchPreferenceState::NotPaired
|
||||
);
|
||||
assert_eq!(
|
||||
watch_preference(true, true, true, 0).0,
|
||||
MobileWatchPreferenceState::Ready
|
||||
);
|
||||
let pending = watch_preference(true, true, true, 2);
|
||||
assert_eq!(pending.0, MobileWatchPreferenceState::Pending);
|
||||
assert!(pending.2.contains("2 selected TOTP codes"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user