Implement iPhone and Apple Watch preferences (#53)
This commit is contained in:
@@ -134,7 +134,7 @@ struct LeaseState {
|
||||
|
||||
struct Shared<B: SecretStoreBackend, C: AuthenticationClock> {
|
||||
store: SecretStore<B>,
|
||||
timeout: AuthenticationTimeout,
|
||||
timeout: Mutex<AuthenticationTimeout>,
|
||||
clock: C,
|
||||
/// Serializes access checks with relock, eliminating check-then-use races.
|
||||
operation: Mutex<()>,
|
||||
@@ -168,9 +168,14 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationSess
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let timeout = self
|
||||
.shared
|
||||
.timeout
|
||||
.lock()
|
||||
.map_or_else(|poisoned| *poisoned.into_inner(), |timeout| *timeout);
|
||||
formatter
|
||||
.debug_struct("AuthenticationSession")
|
||||
.field("timeout", &self.shared.timeout)
|
||||
.field("timeout", &timeout)
|
||||
.field("unlock_material", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -203,7 +208,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
// The lease is the only cache. This prevents a second lifetime
|
||||
// policy in SecretStore from retaining duplicate records.
|
||||
store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections),
|
||||
timeout,
|
||||
timeout: Mutex::new(timeout),
|
||||
clock,
|
||||
operation: Mutex::new(()),
|
||||
state: Mutex::new(LeaseState {
|
||||
@@ -308,6 +313,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.revoke(RevocationReason::Cancelled)
|
||||
}
|
||||
|
||||
/// Apply a new shared inactivity timeout to this process and any active lease.
|
||||
pub fn set_timeout(&self, timeout: AuthenticationTimeout) -> Result<(), AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.expire_if_needed()?;
|
||||
let deadline = self
|
||||
.shared
|
||||
.clock
|
||||
.now()
|
||||
.checked_add(timeout.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
*self.shared.timeout()? = timeout;
|
||||
if let Some(active) = self.shared.state()?.active.as_mut() {
|
||||
active.deadline = deadline;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generation-bound secret access. Clones are revoked together.
|
||||
@@ -351,7 +373,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
||||
self.shared.expire_if_needed()?;
|
||||
let now = self.shared.clock.now();
|
||||
let deadline = now
|
||||
.checked_add(self.shared.timeout.duration())
|
||||
.checked_add(self.shared.timeout()?.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
self.shared
|
||||
.with_active(self.generation, |active| active.deadline = deadline)
|
||||
@@ -432,10 +454,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
||||
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
||||
}
|
||||
|
||||
fn timeout(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, AuthenticationTimeout>, AuthenticationError> {
|
||||
self.timeout
|
||||
.lock()
|
||||
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
||||
}
|
||||
|
||||
fn begin_lease(&self) -> Result<u64, AuthenticationError> {
|
||||
let now = self.clock.now();
|
||||
let deadline = now
|
||||
.checked_add(self.timeout.duration())
|
||||
.checked_add(self.timeout()?.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
let (generation, was_active) = {
|
||||
let mut state = self.state()?;
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct Config {
|
||||
clipboard_timeout: ClipboardTimeout,
|
||||
authentication_timeout: AuthenticationTimeout,
|
||||
biometric_unlock_enabled: bool,
|
||||
mobile_appearance: MobileAppearance,
|
||||
mobile_tab: MobileTab,
|
||||
mobile_home_refreshed_at: Option<i64>,
|
||||
watch_shared_totp_entries: BTreeSet<EntryPath>,
|
||||
@@ -60,6 +61,35 @@ pub struct ConfigSettings {
|
||||
authentication_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum MobileAppearance {
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl MobileAppearance {
|
||||
fn from_config(value: &str) -> Result<Self, ConfigError> {
|
||||
match value {
|
||||
"system" => Ok(Self::System),
|
||||
"light" => Ok(Self::Light),
|
||||
"dark" => Ok(Self::Dark),
|
||||
_ => Err(ConfigError::InvalidField {
|
||||
field: "ui.mobile_appearance",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const fn config_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigSettings {
|
||||
pub fn vault(&self) -> &Path {
|
||||
&self.vault
|
||||
@@ -133,6 +163,10 @@ impl Config {
|
||||
self.biometric_unlock_enabled
|
||||
}
|
||||
|
||||
pub fn mobile_appearance(&self) -> MobileAppearance {
|
||||
self.mobile_appearance
|
||||
}
|
||||
|
||||
pub fn mobile_tab(&self) -> MobileTab {
|
||||
self.mobile_tab
|
||||
}
|
||||
@@ -280,6 +314,66 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub fn update_authentication_timeout(
|
||||
&self,
|
||||
timeout: AuthenticationTimeout,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let security = root
|
||||
.entry("security")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "security" })?;
|
||||
let seconds =
|
||||
i64::try_from(timeout.duration().as_secs()).map_err(|_| ConfigError::InvalidField {
|
||||
field: "security.inactivity_timeout_seconds",
|
||||
})?;
|
||||
security.insert(
|
||||
"inactivity_timeout_seconds".to_owned(),
|
||||
toml::Value::Integer(seconds),
|
||||
);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub fn update_mobile_appearance(
|
||||
&self,
|
||||
appearance: MobileAppearance,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let ui = root
|
||||
.entry("ui")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||
.as_table_mut()
|
||||
.ok_or(ConfigError::InvalidField { field: "ui" })?;
|
||||
ui.insert(
|
||||
"mobile_appearance".to_owned(),
|
||||
toml::Value::String(appearance.config_value().to_owned()),
|
||||
);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> {
|
||||
let mut document = self.current_document()?;
|
||||
let root = document
|
||||
@@ -952,6 +1046,7 @@ struct RawSecurity {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawUi {
|
||||
selected_mobile_tab: Option<String>,
|
||||
mobile_appearance: Option<String>,
|
||||
home_remote_refreshed_at_unix_seconds: Option<i64>,
|
||||
#[serde(default)]
|
||||
watch_shared_totp_entries: Vec<String>,
|
||||
@@ -1032,6 +1127,13 @@ fn validate_config(
|
||||
field: "security.inactivity_timeout_seconds",
|
||||
})?;
|
||||
let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
|
||||
let mobile_appearance = raw
|
||||
.ui
|
||||
.mobile_appearance
|
||||
.as_deref()
|
||||
.map(MobileAppearance::from_config)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mobile_tab = raw
|
||||
.ui
|
||||
.selected_mobile_tab
|
||||
@@ -1083,6 +1185,7 @@ fn validate_config(
|
||||
clipboard_timeout,
|
||||
authentication_timeout,
|
||||
biometric_unlock_enabled,
|
||||
mobile_appearance,
|
||||
mobile_tab,
|
||||
mobile_home_refreshed_at,
|
||||
watch_shared_totp_entries,
|
||||
@@ -1270,6 +1373,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
||||
"ui",
|
||||
&[
|
||||
"selected_mobile_tab",
|
||||
"mobile_appearance",
|
||||
"home_remote_refreshed_at_unix_seconds",
|
||||
"watch_shared_totp_entries",
|
||||
],
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,29 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return only the non-secret account identity for one configured Git credential.
|
||||
pub fn https_git_credential_account(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<String, SecretStoreError> {
|
||||
self.retrieve_git_record(server, application)?
|
||||
.reference
|
||||
.account()
|
||||
.map(str::to_owned)
|
||||
.ok_or(SecretStoreError::Corrupted)
|
||||
}
|
||||
|
||||
/// Remove the credential selected by its configured server/application identity.
|
||||
pub fn delete_https_git_credential(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let record = self.retrieve_git_record(server, application)?;
|
||||
self.delete(&record.reference)
|
||||
}
|
||||
|
||||
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||
let locator = reference.locator();
|
||||
let mut state = self.unlocked_state()?;
|
||||
|
||||
@@ -249,6 +249,30 @@ fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_updates_apply_to_the_active_lease_and_later_activity() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend, clock.clone(), 120)?;
|
||||
let handle = session.authenticate(&alice)?;
|
||||
|
||||
clock.advance(Duration::from_secs(20));
|
||||
session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?;
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
|
||||
clock.advance(Duration::from_secs(10));
|
||||
handle.touch_user_activity()?;
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
@@ -4,8 +4,10 @@ use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time:
|
||||
|
||||
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
|
||||
use ironstorage::{
|
||||
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
|
||||
config::{ConfigError, ConfigLoader, EditorSource},
|
||||
authentication::{
|
||||
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
|
||||
},
|
||||
config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance},
|
||||
desktop::DesktopStorage,
|
||||
git::GitIdentity,
|
||||
mobile::MobileTab,
|
||||
@@ -299,6 +301,29 @@ fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_timeout_and_appearance_share_the_secret_free_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||
fixture.write_explicit(fixture.valid_contents())?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(config.mobile_appearance(), MobileAppearance::System);
|
||||
|
||||
config.update_mobile_appearance(MobileAppearance::Dark)?;
|
||||
config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark);
|
||||
assert_eq!(
|
||||
reloaded.authentication_timeout().duration(),
|
||||
Duration::from_secs(300)
|
||||
);
|
||||
let contents = fs::read_to_string(fixture.explicit_path())?;
|
||||
assert!(contents.contains("mobile_appearance = \"dark\""));
|
||||
assert!(contents.contains("inactivity_timeout_seconds = 300"));
|
||||
assert!(!contents.to_ascii_lowercase().contains("token ="));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::{
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigLoader,
|
||||
config::{ConfigLoader, GitRemote},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
|
||||
git::{GitCredentialProvider as _, GitError},
|
||||
repository::{EncryptedEntry, SecretBytes},
|
||||
@@ -325,6 +325,40 @@ fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let store = SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
let remote = GitRemote::https(
|
||||
"origin",
|
||||
"https://git.example.test/alice/store.git",
|
||||
"personal-git",
|
||||
"ironstorage-mobile",
|
||||
)?;
|
||||
store.unlock()?;
|
||||
store.store_https_git_credential(
|
||||
remote.server_id(),
|
||||
remote.application_id(),
|
||||
"alice",
|
||||
SecretBytes::new(b"private-token".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.https_git_credential_account(remote.server_id(), remote.application_id())?,
|
||||
"alice"
|
||||
);
|
||||
store.delete_https_git_credential(remote.server_id(), remote.application_id())?;
|
||||
assert!(matches!(
|
||||
store.https_git_credential_account(remote.server_id(), remote.application_id()),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
assert!(!format!("{store:?}").contains("private-token"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
Reference in New Issue
Block a user