Implement iPhone TOTP tab and Watch sharing
This commit is contained in:
301
crates/storage/src/mobile_totp.rs
Normal file
301
crates/storage/src/mobile_totp.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user