2256 lines
76 KiB
Rust
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"));
|
|
}
|
|
}
|