Synchronize selected TOTP entries to Apple Watch (#55)

This commit is contained in:
hugo committed 2026-08-16 13:47:31 +02:00
1 parent affdb55519
commit a055c93b86
12 files changed
+1888 -98

No files matched your search

+181 -36
View File
@@ -23,6 +23,10 @@ use crate::{
mobile_totp::{
MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService,
},
mobile_watch::{
MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError,
WatchSnapshotSender, WatchSnapshotTransfer,
},
otp::OtpError,
recipient::RecipientPolicyManager,
repository::{
@@ -139,6 +143,9 @@ pub enum MobileWatchPreferenceState {
AppNotInstalled,
Ready,
Pending,
Delivered,
Current,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -282,6 +289,7 @@ struct MobileAuthenticationStatus {
mutation_active: bool,
repository_operation_active: bool,
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
watch_snapshot: WatchSnapshotSender,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -338,6 +346,8 @@ impl MobileAuthentication {
config.authentication_timeout(),
)
.map_err(MobileAuthenticationError::authentication)?;
let watch_snapshot =
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
Ok(Self {
status: Mutex::new(MobileAuthenticationStatus {
biometric_unlock_enabled: config.biometric_unlock_enabled(),
@@ -350,6 +360,7 @@ impl MobileAuthentication {
mutation_active: false,
repository_operation_active: false,
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
watch_snapshot,
}),
config,
repository,
@@ -564,8 +575,14 @@ impl MobileAuthentication {
.ok_or_else(|| config_detail("The default GPG key is unavailable."))?;
let status = self.status()?;
let selected = status.watch_shared_totp_entries.len();
let (watch_state, watch_title, watch_detail) =
watch_preference(watch_supported, watch_paired, watch_app_installed, selected);
let snapshot = status.watch_snapshot.status();
let (watch_state, watch_title, watch_detail) = watch_preference(
watch_supported,
watch_paired,
watch_app_installed,
selected,
&snapshot,
);
let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs();
let biometric_unlock_enabled = status.biometric_unlock_enabled;
let appearance = status.appearance;
@@ -681,7 +698,10 @@ impl MobileAuthentication {
&cache_path,
)
.map_err(totp_error)?;
let (totp, cache_notice) = reconciliation.into_parts();
let (mut totp, cache_notice) = reconciliation.into_parts();
if let Some(detail) = &mut totp {
detail.set_watch(self.watch_snapshot_status()?);
}
Ok(MobileEntryPresentation {
page: MobileEntryPage::from_document(&document),
totp,
@@ -808,9 +828,11 @@ impl MobileAuthentication {
};
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
let mut provider = KeyOnlyProvider::new(handle, &key);
MobileTotpService::new(&self.repository, &self.keys)
let mut page = MobileTotpService::new(&self.repository, &self.keys)
.discover(&shared, &mut provider, &cache_path, operation)
.map_err(totp_error)
.map_err(totp_error)?;
page.set_watch(self.watch_snapshot_status()?);
Ok(page)
}
pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
@@ -823,13 +845,15 @@ impl MobileAuthentication {
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
let shared = self.status()?.watch_shared_totp_entries.clone();
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
Ok(
MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
&shared,
&cache_path,
query,
),
)
let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
&shared,
&cache_path,
query,
);
if let Some(page) = &mut page {
page.set_watch(self.watch_snapshot_status()?);
}
Ok(page)
}
pub fn totp_detail(
@@ -848,9 +872,11 @@ impl MobileAuthentication {
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
MobileTotpService::new(&self.repository, &self.keys)
let mut detail = MobileTotpService::new(&self.repository, &self.keys)
.detail(path, unix_seconds, &shared, &mut provider)
.map_err(totp_error)
.map_err(totp_error)?;
detail.set_watch(self.watch_snapshot_status()?);
Ok(detail)
}
pub fn copy_totp_code(
@@ -888,6 +914,85 @@ impl MobileAuthentication {
self.totp_detail(path, unix_seconds)
}
pub fn watch_snapshot_status(
&self,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
Ok(self.status()?.watch_snapshot.status())
}
pub fn prepare_watch_snapshot(
&self,
platform_pairing_identity: &str,
) -> Result<WatchSnapshotTransfer, MobileAuthenticationError> {
self.ensure_active()?;
let (handle, key, selected) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(
active.handle.clone(),
active.key.clone(),
status.watch_shared_totp_entries.clone(),
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
let selection = MobileTotpService::new(&self.repository, &self.keys)
.watch_snapshot_selection(&selected, &mut provider)
.map_err(totp_error)?;
let (entries, retained) = selection.into_parts();
let mut status = self.status()?;
if status.watch_shared_totp_entries != selected {
return Err(entry_detail(
"Apple Watch Snapshot Changed",
"the selected TOTP entries changed while the snapshot was being prepared",
));
}
if retained != selected {
drop(status);
self.config
.update_watch_shared_totp_entries(&retained)
.map_err(config_error)?;
status = self.status()?;
status.watch_shared_totp_entries = retained;
}
status
.watch_snapshot
.prepare(platform_pairing_identity, entries)
.map_err(watch_error)
}
pub fn set_watch_snapshot_unavailable(
&self,
unpaired: bool,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
let mut status = self.status()?;
status
.watch_snapshot
.unavailable(unpaired, detail)
.map_err(watch_error)?;
Ok(status.watch_snapshot.status())
}
pub fn fail_watch_snapshot(
&self,
revision: u64,
detail: String,
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
let mut status = self.status()?;
status.watch_snapshot.failed(revision, detail);
Ok(status.watch_snapshot.status())
}
pub fn acknowledge_watch_snapshot(
&self,
receipt: &[u8],
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
self.status()?
.watch_snapshot
.acknowledge(receipt)
.map_err(watch_error)
}
pub fn replace_entry_field(
&self,
path: &str,
@@ -1430,6 +1535,7 @@ fn watch_preference(
paired: bool,
app_installed: bool,
selected: usize,
snapshot: &MobileWatchSnapshotStatus,
) -> (MobileWatchPreferenceState, String, String) {
if !supported {
return (
@@ -1452,25 +1558,50 @@ fn watch_preference(
"Install the IronStorage companion on the paired Apple Watch.".to_owned(),
);
}
if selected == 0 {
return (
MobileWatchPreferenceState::Ready,
"Ready".to_owned(),
"No TOTP codes are selected for Apple Watch.".to_owned(),
);
}
(
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
format!(
"{selected} selected TOTP {} pending Apple Watch synchronization.",
if selected == 1 {
"code is"
} else {
"codes are"
}
match snapshot.state() {
MobileWatchSnapshotState::Pending => (
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
snapshot.detail().to_owned(),
),
)
MobileWatchSnapshotState::Delivered => (
MobileWatchPreferenceState::Delivered,
"Delivered".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Current => (
MobileWatchPreferenceState::Current,
"Current".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Failed => (
MobileWatchPreferenceState::Failed,
"Synchronization Failed".to_owned(),
snapshot.detail().to_owned(),
),
MobileWatchSnapshotState::Unavailable | MobileWatchSnapshotState::Unpaired => {
if selected == 0 {
(
MobileWatchPreferenceState::Ready,
"Ready".to_owned(),
"No TOTP codes are selected for Apple Watch.".to_owned(),
)
} else {
(
MobileWatchPreferenceState::Pending,
"Synchronization Pending".to_owned(),
format!(
"{selected} selected TOTP {} pending Apple Watch synchronization.",
if selected == 1 {
"code is"
} else {
"codes are"
}
),
)
}
}
}
}
fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
@@ -1565,6 +1696,14 @@ fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
}
}
fn watch_error(error: WatchSnapshotError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
"Apple Watch Synchronization Failed",
error.to_string(),
)
}
fn editor_missing() -> MobileAuthenticationError {
entry_detail(
"Entry Draft Is Unavailable",
@@ -1584,7 +1723,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
mod tests {
use super::{
MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
repository_operation_conflict, watch_preference,
MobileWatchSnapshotState, MobileWatchSnapshotStatus, repository_operation_conflict,
watch_preference,
};
#[test]
@@ -1613,15 +1753,20 @@ mod tests {
#[test]
fn watch_preferences_render_platform_and_selection_state() {
let unavailable = MobileWatchSnapshotStatus {
state: MobileWatchSnapshotState::Unavailable,
revision: None,
detail: "not started".to_owned(),
};
assert_eq!(
watch_preference(true, false, false, 2).0,
watch_preference(true, false, false, 2, &unavailable).0,
MobileWatchPreferenceState::NotPaired
);
assert_eq!(
watch_preference(true, true, true, 0).0,
watch_preference(true, true, true, 0, &unavailable).0,
MobileWatchPreferenceState::Ready
);
let pending = watch_preference(true, true, true, 2);
let pending = watch_preference(true, true, true, 2, &unavailable);
assert_eq!(pending.0, MobileWatchPreferenceState::Pending);
assert!(pending.2.contains("2 selected TOTP codes"));
}