//! Storage-owned TOTP catalog and detail state for native mobile frontends. use std::{collections::BTreeSet, error::Error, fmt}; use crate::{ crypto::{KeyStore, SecretProvider}, otp::{OtpError, OtpKind, OtpService}, repository::{EntryPath, Repository, RepositoryError, SecretBytes}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileWatchSnapshotState { Unavailable, Pending, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileWatchSnapshotStatus { state: MobileWatchSnapshotState, detail: String, } impl MobileWatchSnapshotStatus { pub fn state(&self) -> MobileWatchSnapshotState { self.state } pub fn detail(&self) -> &str { &self.detail } } #[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, watch: MobileWatchSnapshotStatus, } impl MobileTotpPage { pub fn rows(&self) -> &[MobileTotpRow] { &self.rows } pub fn unavailable_entries(&self) -> u32 { self.unavailable_entries } pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.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 } } 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 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(_) => {} } } 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)) }); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); Ok(MobileTotpPage { rows, unavailable_entries, watch: snapshot_status(selected), }) } 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()); } 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)?; let shared_with_watch = shared.contains(&path); 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, watch: snapshot_status(shared.len()), }) } } 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 snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { if selected == 0 { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Unavailable, detail: "No TOTP codes are selected for Apple Watch.".to_owned(), } } else { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Pending, 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), } 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), } } } 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) } }