//! Storage-owned TOTP catalog and detail state for native mobile frontends. use std::{ collections::{BTreeMap, BTreeSet}, error::Error, fmt, fs, io::{Read as _, Write as _}, path::Path, sync::{ Mutex, atomic::{AtomicBool, Ordering}, }, }; use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; use data_encoding::HEXLOWER; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use crate::{ crypto::{KeyStore, SecretProvider}, document::EntryDocument, mobile_watch::{MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotEntry}, otp::{OtpError, OtpKind, OtpService}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, }; const CACHE_VERSION: u32 = 1; const MAX_CATALOG_BYTES: u64 = 32 * 1024 * 1024; // ponytail: cache writes are rare; use per-cache locks only if profiling shows contention. static CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(()); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileTotpDiscoveryPhase { Preparing, Inspecting, Saving, Complete, Cancelled, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileTotpDiscoveryProgress { phase: MobileTotpDiscoveryPhase, total: u32, inspected: u32, cache_hits: u32, matches: u32, unavailable: u32, } impl MobileTotpDiscoveryProgress { pub fn phase(&self) -> MobileTotpDiscoveryPhase { self.phase } pub fn total(&self) -> u32 { self.total } pub fn inspected(&self) -> u32 { self.inspected } pub fn cache_hits(&self) -> u32 { self.cache_hits } pub fn matches(&self) -> u32 { self.matches } pub fn unavailable(&self) -> u32 { self.unavailable } } pub struct MobileTotpOperation { cancelled: AtomicBool, progress: Mutex, } impl Default for MobileTotpOperation { fn default() -> Self { Self { cancelled: AtomicBool::new(false), progress: Mutex::new(MobileTotpDiscoveryProgress { phase: MobileTotpDiscoveryPhase::Preparing, total: 0, inspected: 0, cache_hits: 0, matches: 0, unavailable: 0, }), } } } impl MobileTotpOperation { pub fn cancel(&self) { self.cancelled.store(true, Ordering::Relaxed); self.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Cancelled); } pub fn progress(&self) -> MobileTotpDiscoveryProgress { self.progress.lock().map_or_else( |poisoned| poisoned.into_inner().clone(), |value| value.clone(), ) } fn check_cancelled(&self) -> Result<(), MobileTotpError> { if self.cancelled.load(Ordering::Relaxed) { Err(OtpError::Cancelled.into()) } else { Ok(()) } } fn update(&self, update: impl FnOnce(&mut MobileTotpDiscoveryProgress)) { match self.progress.lock() { Ok(mut progress) => update(&mut progress), Err(poisoned) => update(&mut poisoned.into_inner()), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileTotpRow { path: String, issuer: Option, account: String, title: String, detail: String, shared_with_watch: bool, } impl MobileTotpRow { pub fn path(&self) -> &str { &self.path } pub fn issuer(&self) -> Option<&str> { self.issuer.as_deref() } pub fn account(&self) -> &str { &self.account } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn shared_with_watch(&self) -> bool { self.shared_with_watch } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileTotpPage { rows: Vec, unavailable_entries: u32, cache_notice: Option, watch: MobileWatchSnapshotStatus, } impl MobileTotpPage { pub fn rows(&self) -> &[MobileTotpRow] { &self.rows } pub fn unavailable_entries(&self) -> u32 { self.unavailable_entries } pub fn cache_notice(&self) -> Option<&str> { self.cache_notice.as_deref() } pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.watch } pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) { self.watch = watch; } } pub struct MobileTotpDetail { path: String, issuer: Option, account: String, code: SecretBytes, valid_until: u64, period: u64, shared_with_watch: bool, watch: MobileWatchSnapshotStatus, } impl MobileTotpDetail { pub fn path(&self) -> &str { &self.path } pub fn issuer(&self) -> Option<&str> { self.issuer.as_deref() } pub fn account(&self) -> &str { &self.account } pub fn code(&self) -> &SecretBytes { &self.code } pub fn valid_until(&self) -> u64 { self.valid_until } pub fn period(&self) -> u64 { self.period } pub fn shared_with_watch(&self) -> bool { self.shared_with_watch } pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.watch } pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) { self.watch = watch; } } pub struct WatchSnapshotSelection { entries: Vec, retained: BTreeSet, } impl WatchSnapshotSelection { pub fn into_parts(self) -> (Vec, BTreeSet) { (self.entries, self.retained) } } impl fmt::Debug for MobileTotpDetail { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("MobileTotpDetail") .field("path", &self.path) .field("issuer", &self.issuer) .field("account", &self.account) .field("code", &"[REDACTED]") .field("valid_until", &self.valid_until) .field("period", &self.period) .field("shared_with_watch", &self.shared_with_watch) .field("watch", &self.watch) .finish() } } pub struct MobileTotpReconciliation { detail: Option, cache_notice: Option, } impl MobileTotpReconciliation { pub fn detail(&self) -> Option<&MobileTotpDetail> { self.detail.as_ref() } pub fn cache_notice(&self) -> Option<&str> { self.cache_notice.as_deref() } pub fn into_parts(self) -> (Option, Option) { (self.detail, self.cache_notice) } } pub struct MobileTotpService<'a> { repository: &'a Repository, keys: &'a KeyStore, } impl<'a> MobileTotpService<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { Self { repository, keys } } pub fn page( &self, shared: &BTreeSet, provider: &mut impl SecretProvider, ) -> Result { let snapshot = self.repository.snapshot()?; let mut rows = Vec::new(); let mut unavailable_entries = 0_u32; for entry in snapshot.entries() { match OtpService::new(self.repository, self.keys) .uri(&entry.path().to_string(), provider) { Ok(uri) if uri.kind() == OtpKind::Totp => rows.push(row( entry.path(), uri.issuer(), uri.account(), shared.contains(entry.path()), )), Ok(_) | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => {} Err(OtpError::Crypto(_)) => { unavailable_entries = unavailable_entries.saturating_add(1); } Err(OtpError::Repository(error)) => return Err(error.into()), Err(_) => {} } } sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); Ok(MobileTotpPage { rows, unavailable_entries, cache_notice: None, watch: snapshot_status(selected), }) } pub fn discover( &self, shared: &BTreeSet, provider: &mut impl SecretProvider, cache_path: &Path, operation: &MobileTotpOperation, ) -> Result { operation.check_cancelled()?; let initial_inventory = inventory(self.repository)?; operation.update(|progress| { progress.phase = MobileTotpDiscoveryPhase::Inspecting; progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX); }); let store = store_identity(self.repository.root_path(), cache_path); let (cached, mut cache_notice) = load_cache(cache_path, &store); let mut records = BTreeMap::new(); let mut rows = Vec::new(); let mut unavailable_entries = 0_u32; for (path, encrypted, ciphertext_hash) in &initial_inventory { operation.check_cancelled()?; let path_text = path.to_string(); let cached_record = cached .as_ref() .and_then(|catalog| catalog.entries.get(&path_text)) .filter(|record| record.ciphertext_hash == *ciphertext_hash); let (record, changed) = if let Some(record) = cached_record { operation .update(|progress| progress.cache_hits = progress.cache_hits.saturating_add(1)); (record.clone(), false) } else { match inspect_entry(self.repository, self.keys, path, encrypted, provider) { Ok(record) => (record.with_ciphertext_hash(ciphertext_hash.clone()), true), Err(OtpError::Crypto(_)) => { unavailable_entries = unavailable_entries.saturating_add(1); operation.update(|progress| { progress.unavailable = progress.unavailable.saturating_add(1) }); operation.update(|progress| { progress.inspected = progress.inspected.saturating_add(1) }); continue; } Err(OtpError::Repository(error)) => return Err(error.into()), Err(error) => return Err(error.into()), } }; if record.is_totp { rows.push(cached_row(path, shared.contains(path))); operation.update(|progress| progress.matches = progress.matches.saturating_add(1)); } if changed { cache_notice = upsert_cache_record(cache_path, &store, path_text.clone(), record.clone()) .or(cache_notice); } records.insert(path_text, record); operation.update(|progress| progress.inspected = progress.inspected.saturating_add(1)); } operation.check_cancelled()?; if initial_inventory != inventory(self.repository)? { return Err(MobileTotpError::ConcurrentModification); } operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Saving); let catalog = CachedCatalog { version: CACHE_VERSION, store, entries: records, }; if let Err(error) = save_cache(cache_path, &catalog) { cache_notice = Some(error.to_string()); } sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Complete); Ok(MobileTotpPage { rows, unavailable_entries, cache_notice, watch: snapshot_status(selected), }) } pub fn cached_page( &self, shared: &BTreeSet, cache_path: &Path, ) -> Option { self.search_cached_page(shared, cache_path, "") } pub fn search_cached_page( &self, shared: &BTreeSet, cache_path: &Path, query: &str, ) -> Option { let store = store_identity(self.repository.root_path(), cache_path); let (catalog, _) = load_cache(cache_path, &store); let query = query.trim().to_lowercase(); let mut rows: Vec<_> = catalog? .entries .into_iter() .filter(|(_, record)| record.is_totp) .filter_map(|(path, record)| { let path = EntryPath::parse(&path).ok()?; let ciphertext = self.repository.read_entry(&path).ok()?; (digest(ciphertext.as_bytes()) == record.ciphertext_hash).then_some(path) }) .filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query)) .map(|path| cached_row(&path, shared.contains(&path))) .collect(); sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); Some(MobileTotpPage { rows, unavailable_entries: 0, cache_notice: None, watch: snapshot_status(selected), }) } pub fn reconcile_document( &self, document: &EntryDocument, ciphertext: &EncryptedEntry, unix_seconds: Option, shared: &BTreeSet, cache_path: &Path, ) -> Result { let uri = document .otp_uri() .ok() .flatten() .filter(|uri| uri.kind() == OtpKind::Totp); let record = CachedRecord { ciphertext_hash: digest(ciphertext.as_bytes()), is_totp: uri.is_some(), }; let store = store_identity(self.repository.root_path(), cache_path); let cache_notice = upsert_cache_record(cache_path, &store, document.path().to_string(), record); let detail = match (uri, unix_seconds) { (Some(uri), Some(unix_seconds)) => { Some(detail_for_uri(document.path(), uri, unix_seconds, shared)?) } _ => None, }; Ok(MobileTotpReconciliation { detail, cache_notice, }) } pub fn detail( &self, entry: &str, unix_seconds: u64, shared: &BTreeSet, provider: &mut impl SecretProvider, ) -> Result { let path = EntryPath::parse(entry)?; let uri = OtpService::new(self.repository, self.keys).uri(entry, provider)?; if uri.kind() != OtpKind::Totp { return Err(OtpError::NotTotp.into()); } detail_for_uri(&path, uri, unix_seconds, shared) } pub fn watch_snapshot_selection( &self, selected: &BTreeSet, provider: &mut impl SecretProvider, ) -> Result { let mut entries = Vec::with_capacity(selected.len()); let mut retained = BTreeSet::new(); for path in selected { let uri = match OtpService::new(self.repository, self.keys) .uri(&path.to_string(), provider) { Ok(uri) if uri.kind() == OtpKind::Totp => uri, Ok(_) => continue, Err(OtpError::Repository(RepositoryError::NotFound { .. })) | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => continue, Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error).into()), Err(OtpError::Repository(error)) => return Err(error.into()), Err(_) => continue, }; let period = uri.period().ok_or(OtpError::NotTotp)?; retained.insert(path.clone()); entries.push(WatchSnapshotEntry::new( path.to_string(), uri.issuer().map(str::to_owned), uri.account().to_owned(), uri.algorithm(), uri.digits(), period, uri.watch_secret(), )); } Ok(WatchSnapshotSelection { entries, retained }) } } fn detail_for_uri( path: &EntryPath, uri: crate::otp::OtpUri, unix_seconds: u64, shared: &BTreeSet, ) -> Result { let period = uri.period().ok_or(OtpError::NotTotp)?; let valid_until = (unix_seconds / period) .checked_add(1) .and_then(|counter| counter.checked_mul(period)) .ok_or(OtpError::CounterOverflow)?; Ok(MobileTotpDetail { path: path.to_string(), issuer: uri.issuer().map(str::to_owned), account: uri.account().to_owned(), code: uri.code_at(unix_seconds)?, valid_until, period, shared_with_watch: shared.contains(path), watch: snapshot_status(shared.len()), }) } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CachedRecord { ciphertext_hash: String, is_totp: bool, } impl CachedRecord { fn with_ciphertext_hash(mut self, ciphertext_hash: String) -> Self { self.ciphertext_hash = ciphertext_hash; self } } #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] struct CachedCatalog { version: u32, store: String, entries: BTreeMap, } fn inventory( repository: &Repository, ) -> Result, RepositoryError> { repository .snapshot()? .entries() .map(|entry| { let encrypted = repository.read_entry(entry.path())?; let ciphertext_hash = digest(encrypted.as_bytes()); Ok((entry.path().clone(), encrypted, ciphertext_hash)) }) .collect() } fn inspect_entry( repository: &Repository, keys: &KeyStore, path: &EntryPath, encrypted: &EncryptedEntry, provider: &mut impl SecretProvider, ) -> Result { let is_totp = match OtpService::new(repository, keys).uri_encrypted(path, encrypted, provider) { Ok(uri) => uri.kind() == OtpKind::Totp, Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => false, Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error)), Err(OtpError::Repository(error)) => return Err(OtpError::Repository(error)), Err(_) => false, }; Ok(CachedRecord { ciphertext_hash: String::new(), is_totp, }) } fn load_cache(path: &Path, store: &str) -> (Option, Option) { if let Ok(metadata) = fs::symlink_metadata(path) && (metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_CATALOG_BYTES) { return ( None, Some("The TOTP cache was not a regular file and was rebuilt.".to_owned()), ); } let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (None, None), Err(_) => { return ( None, Some("The TOTP cache could not be read and was rebuilt.".to_owned()), ); } }; let catalog = toml::from_str::(&contents) .ok() .filter(|catalog| catalog.version == CACHE_VERSION && catalog.store == store); match catalog { Some(catalog) => (Some(catalog), None), None => ( None, Some("The TOTP cache was outdated or damaged and was rebuilt.".to_owned()), ), } } pub(crate) fn validate_private_catalog( root: &Path, path: &Path, ) -> Result { use crate::mobile_private_storage::PrivateStorageError; let _lock = CACHE_WRITE_LOCK .lock() .map_err(|_| PrivateStorageError::Io)?; let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error.into()), }; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(PrivateStorageError::UnsafePath); } let store = store_identity(root, path); let parent = Dir::open_ambient_dir( path.parent().ok_or(PrivateStorageError::UnsafePath)?, ambient_authority(), )?; let name = path.file_name().ok_or(PrivateStorageError::UnsafePath)?; let file = parent.open(name)?; crate::mobile_private_storage::private_permissions(&file, false)?; let mut bytes = Vec::new(); if metadata.len() <= MAX_CATALOG_BYTES { file.take(MAX_CATALOG_BYTES + 1).read_to_end(&mut bytes)?; } let source = std::str::from_utf8(&bytes).ok(); let valid = metadata.len() <= MAX_CATALOG_BYTES && bytes.len() as u64 <= MAX_CATALOG_BYTES && source .and_then(|source| toml::from_str::(source).ok()) .is_some_and(|catalog| { catalog.version == CACHE_VERSION && catalog.store == store && catalog.entries.iter().all(|(entry, record)| { EntryPath::parse(entry).is_ok() && record.ciphertext_hash.len() == 64 && record .ciphertext_hash .bytes() .all(|byte| byte.is_ascii_hexdigit()) }) }); if valid { return Ok(false); } parent.remove_file(name)?; parent.open(".")?.sync_all()?; Ok(true) } fn upsert_cache_record( path: &Path, store: &str, entry: String, record: CachedRecord, ) -> Option { let _write = CACHE_WRITE_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (catalog, notice) = load_cache(path, store); let mut catalog = catalog.unwrap_or_else(|| CachedCatalog { version: CACHE_VERSION, store: store.to_owned(), entries: BTreeMap::new(), }); if catalog.entries.get(&entry) == Some(&record) { return notice; } catalog.entries.insert(entry, record); save_cache_unlocked(path, &catalog) .err() .map(|error| error.to_string()) .or(notice) } fn save_cache(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> { let _write = CACHE_WRITE_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); save_cache_unlocked(path, catalog) } fn save_cache_unlocked(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> { let serialized = toml::to_string(catalog).map_err(|_| MobileTotpCacheError::Encode)?; let parent = path.parent().ok_or(MobileTotpCacheError::Write)?; let name = path.file_name().ok_or(MobileTotpCacheError::Write)?; let directory = Dir::open_ambient_dir(parent, ambient_authority()) .map_err(|_| MobileTotpCacheError::Write)?; if let Ok(metadata) = directory.symlink_metadata(name) && (metadata.file_type().is_symlink() || !metadata.is_file()) { return Err(MobileTotpCacheError::Write); } let mut temporary = TempFile::new(&directory).map_err(|_| MobileTotpCacheError::Write)?; set_private_permissions(&temporary)?; temporary .write_all(serialized.as_bytes()) .and_then(|()| temporary.as_file().sync_all()) .and_then(|()| temporary.replace(name)) .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) .map_err(|_| MobileTotpCacheError::Write) } #[cfg(unix)] fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> { use cap_std::fs::{Permissions, PermissionsExt as _}; temporary .as_file() .set_permissions(Permissions::from_mode(0o600)) .map_err(|_| MobileTotpCacheError::Write) } #[cfg(not(unix))] fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> { Ok(()) } fn store_identity(root: &Path, cache_path: &Path) -> String { let cache_parent = cache_path .parent() .and_then(|parent| fs::canonicalize(parent).ok()); let stable_root = cache_parent .as_deref() .and_then(|parent| root.strip_prefix(parent).ok()) .unwrap_or(root); digest(stable_root.to_string_lossy().as_bytes()) } fn digest(bytes: &[u8]) -> String { HEXLOWER.encode(&Sha256::digest(bytes)) } #[derive(Debug)] enum MobileTotpCacheError { Encode, Write, } impl fmt::Display for MobileTotpCacheError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Encode => formatter.write_str("The TOTP cache could not be encoded."), Self::Write => formatter.write_str("The TOTP cache could not be saved."), } } } fn row( path: &EntryPath, issuer: Option<&str>, account: &str, shared_with_watch: bool, ) -> MobileTotpRow { MobileTotpRow { path: path.to_string(), issuer: issuer.map(str::to_owned), account: account.to_owned(), title: issuer.unwrap_or(account).to_owned(), detail: issuer.map_or_else(|| path.to_string(), |_| account.to_owned()), shared_with_watch, } } fn cached_row(path: &EntryPath, shared_with_watch: bool) -> MobileTotpRow { let path = path.to_string(); let title = path.rsplit('/').next().unwrap_or(&path).to_owned(); MobileTotpRow { path: path.clone(), issuer: None, account: title.clone(), title, detail: path, shared_with_watch, } } fn sort_rows(rows: &mut [MobileTotpRow]) { rows.sort_by(|left, right| { left.title .to_lowercase() .cmp(&right.title.to_lowercase()) .then_with(|| { left.account .to_lowercase() .cmp(&right.account.to_lowercase()) }) .then_with(|| left.path.cmp(&right.path)) }); } fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { if selected == 0 { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Unavailable, revision: None, detail: "No TOTP codes are selected for Apple Watch.".to_owned(), } } else { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Pending, revision: None, detail: format!( "{selected} selected TOTP {} pending Apple Watch synchronization.", if selected == 1 { "code is" } else { "codes are" } ), } } } #[derive(Debug)] pub enum MobileTotpError { Repository(RepositoryError), Otp(OtpError), ConcurrentModification, } impl fmt::Display for MobileTotpError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Repository(error) => error.fmt(formatter), Self::Otp(error) => error.fmt(formatter), Self::ConcurrentModification => formatter .write_str("the password store changed while TOTP discovery was in progress"), } } } impl Error for MobileTotpError {} impl From for MobileTotpError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for MobileTotpError { fn from(error: OtpError) -> Self { Self::Otp(error) } }