Files
IronStorage/crates/storage/src/mobile_totp.rs

302 lines
7.8 KiB
Rust

//! 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<String>,
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<MobileTotpRow>,
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<String>,
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<EntryPath>,
provider: &mut impl SecretProvider,
) -> Result<MobileTotpPage, MobileTotpError> {
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<EntryPath>,
provider: &mut impl SecretProvider,
) -> Result<MobileTotpDetail, MobileTotpError> {
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<RepositoryError> for MobileTotpError {
fn from(error: RepositoryError) -> Self {
Self::Repository(error)
}
}
impl From<OtpError> for MobileTotpError {
fn from(error: OtpError) -> Self {
Self::Otp(error)
}
}