Files
IronStorage/crates/storage/src/mobile_authentication.rs
T
hugo c0ba6d43d8
Android ARM verification / rust (push) Failing after 47m18s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s
Implement structured Android entry viewer (#99)
2026-10-09 17:36:38 +02:00

2256 lines
76 KiB
Rust

//! 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<String>,
) -> Self {
Self {
kind,
title: title.to_owned(),
detail: detail.into(),
}
}
fn passphrase_required(detail: impl Into<String>) -> 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<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 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<MobileTotpDetail>,
cache_notice: Option<String>,
}
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<MobileTotpDetail>, Option<String>) {
(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<ActiveMobileLease>,
next_editor_id: u64,
editors: BTreeMap<u64, MobileEntryDraft>,
mutation_active: bool,
repository_operation_active: bool,
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
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<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
type MobileAuthenticationHandle =
AuthenticationHandle<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
pub struct MobileAuthentication {
config: Config,
repository: Repository,
keys: KeyStore,
session: MobileAuthenticationSession,
backend: Arc<dyn SecretStoreBackend>,
status: Mutex<MobileAuthenticationStatus>,
totp_discovery: Mutex<()>,
}
impl MobileAuthentication {
pub(crate) fn matches_configuration(
&self,
config: &Config,
) -> Result<bool, MobileAuthenticationError> {
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<Self, MobileAuthenticationError> {
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<dyn SecretStoreBackend>,
clock: Arc<dyn AuthenticationClock>,
) -> Result<Self, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<GitIdentity, MobileAuthenticationError> {
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,
email: String,
) -> Result<GitIdentity, MobileAuthenticationError> {
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<MobilePreferences, MobileAuthenticationError> {
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<SecretBytes>,
unix_seconds: u64,
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
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<MobileEntryPresentation, MobileAuthenticationError> {
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<String, MobileAuthenticationError> {
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<String, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileEntryPresentation, MobileAuthenticationError> {
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<EntryDocument, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileMutationPlan, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileMutationPlan, MobileAuthenticationError> {
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<MobileMutationOutcome, MobileAuthenticationError> {
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<MobileMutationOutcome, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileTotpPage, MobileAuthenticationError> {
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<Option<MobileTotpPage>, MobileAuthenticationError> {
self.search_cached_totp_page("")
}
pub fn search_cached_totp_page(
&self,
query: &str,
) -> Result<Option<MobileTotpPage>, 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<MobileTotpDetail, MobileAuthenticationError> {
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<MobileEntryCopy, MobileAuthenticationError> {
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<MobileTotpDetail, MobileAuthenticationError> {
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<MobileWatchSnapshotStatus, MobileAuthenticationError> {
Ok(self.status()?.watch_snapshot.status())
}
pub fn prepare_watch_snapshot(
&self,
platform_pairing_identity: &str,
) -> Result<WatchSnapshotTransfer, MobileAuthenticationError> {
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<MobileWatchSnapshotStatus, MobileAuthenticationError> {
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<MobileWatchSnapshotStatus, MobileAuthenticationError> {
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<MobileWatchSnapshotStatus, MobileAuthenticationError> {
self.status()?
.watch_snapshot
.acknowledge(receipt)
.map_err(watch_error)
}
pub fn replace_entry_field(
&self,
path: &str,
field: u64,
value: String,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryPage, MobileAuthenticationError> {
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<MobileEntryEditorSession, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
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<MobileEntryEditorPage, MobileAuthenticationError> {
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<MobileEntryEditorInput>,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
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<String>,
value: String,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
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<MobileEntryEditorPage, MobileAuthenticationError> {
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<MobileEntryEditorPage, MobileAuthenticationError> {
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<MobileEntryEditorPage, MobileAuthenticationError> {
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<u32>,
no_symbols: bool,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
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<MobileEntryEditorInput>,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
{
self.status.lock().map_err(|_| {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,
"Authentication Is Unavailable",
"The shared authentication state is unavailable.",
)
})
}
fn take_active(&self) -> Result<Option<ActiveMobileLease>, 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<MobileEntryEditorSession, MobileAuthenticationError> {
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<MobileRepositoryOperationGuard<'_>, 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<Vec<(u64, MobileEntryDraft)>, MobileAuthenticationError> {
let mut status = self.status()?;
let ids = status
.editors
.iter()
.filter(|(_, draft)| draft.document().path().to_string() == path)
.map(|(id, _)| *id)
.collect::<Vec<_>>();
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<EntryDocument, MobileAuthenticationError> {
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<MobileRepositoryOperationError> {
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<SecretBytes, SecretProviderError> {
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<dyn SecretStoreBackend>,
) -> SecretStore<Arc<dyn SecretStoreBackend>> {
SecretStore::new(
backend,
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
)
}
fn application_account(
remote: &GitRemote,
backend: Arc<dyn SecretStoreBackend>,
) -> Result<Option<String>, 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"));
}
}