302 lines
7.8 KiB
Rust
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)
|
|
}
|
|
}
|