Implement iPhone and Apple Watch preferences (#53)

This commit is contained in:
2026-08-15 22:37:31 +02:00
parent 81886756a2
commit affdb55519
15 changed files with 1409 additions and 60 deletions

View File

@@ -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()?;

View File

@@ -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",
],

View File

@@ -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"));
}
}

View File

@@ -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()?;

View File

@@ -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()?;

View File

@@ -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()?;

View File

@@ -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()?;