Implement Apple Watch TOTP interface (#57)

This commit is contained in:
2026-08-16 14:38:16 +02:00
parent df1d49339c
commit e54d91a83a
6 changed files with 789 additions and 126 deletions

View File

@@ -327,6 +327,7 @@ pub struct WatchTotpRecord {
code: SecretBytes,
period: u64,
valid_until: u64,
remaining: u64,
}
impl WatchTotpRecord {
@@ -348,6 +349,9 @@ impl WatchTotpRecord {
pub fn valid_until(&self) -> u64 {
self.valid_until
}
pub fn remaining(&self) -> u64 {
self.remaining
}
pub fn remaining_at(&self, unix_seconds: u64) -> u64 {
self.valid_until.saturating_sub(unix_seconds)
}
@@ -363,14 +367,58 @@ impl fmt::Debug for WatchTotpRecord {
.field("code", &"[REDACTED]")
.field("period", &self.period)
.field("valid_until", &self.valid_until)
.field("remaining", &self.remaining)
.finish()
}
}
#[derive(Default)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WatchPresentationState {
Ready,
Empty,
Syncing,
Stale,
Locked,
Unavailable,
Error,
}
pub struct WatchPresentation {
state: WatchPresentationState,
title: String,
detail: String,
records: Vec<WatchTotpRecord>,
}
impl WatchPresentation {
pub fn state(&self) -> WatchPresentationState {
self.state
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
pub fn records(&self) -> &[WatchTotpRecord] {
&self.records
}
}
pub struct WatchRuntime {
receiver: WatchSnapshotReceiver,
protected_data_available: bool,
presentation_state: WatchPresentationState,
}
impl Default for WatchRuntime {
fn default() -> Self {
Self {
receiver: WatchSnapshotReceiver::default(),
protected_data_available: true,
presentation_state: WatchPresentationState::Syncing,
}
}
}
impl WatchRuntime {
@@ -378,8 +426,21 @@ impl WatchRuntime {
&mut self,
bytes: Vec<u8>,
) -> Result<WatchSnapshotUpdate, WatchSnapshotError> {
let apply = self.receiver.apply(SecretBytes::new(bytes))?;
let apply = match self.receiver.apply(SecretBytes::new(bytes)) {
Ok(apply) => apply,
Err(error) => {
self.presentation_state = WatchPresentationState::Error;
return Err(error);
}
};
self.protected_data_available = true;
self.presentation_state = match apply {
WatchSnapshotApply::Revoked => WatchPresentationState::Empty,
WatchSnapshotApply::Stale => WatchPresentationState::Stale,
WatchSnapshotApply::Replaced
| WatchSnapshotApply::Duplicate
| WatchSnapshotApply::PairingChanged => WatchPresentationState::Ready,
};
let persistence = match apply {
WatchSnapshotApply::Replaced | WatchSnapshotApply::PairingChanged => {
WatchPersistenceAction::Replace
@@ -434,19 +495,95 @@ impl WatchRuntime {
code,
period: entry.period,
valid_until,
remaining: valid_until.saturating_sub(unix_seconds),
})
})
.collect()
}
pub fn presentation_at(
&self,
unix_seconds: u64,
) -> Result<WatchPresentation, WatchSnapshotError> {
let records = if matches!(
self.presentation_state,
WatchPresentationState::Ready | WatchPresentationState::Stale
) {
self.records_at(unix_seconds)?
} else {
Vec::new()
};
let (title, detail) = match self.presentation_state {
WatchPresentationState::Ready => ("TOTP Codes", "Select an entry to view its code."),
WatchPresentationState::Empty => (
"No TOTP Codes",
"Select TOTP entries in IronStorage on your iPhone.",
),
WatchPresentationState::Syncing => (
"Syncing",
"Checking for TOTP entries shared by your iPhone.",
),
WatchPresentationState::Stale => (
"Update Delayed",
"Showing saved codes; the received update was older.",
),
WatchPresentationState::Locked => (
"Watch Locked",
"Unlock your Watch to access shared TOTP codes.",
),
WatchPresentationState::Unavailable => (
"Sync Unavailable",
"Open IronStorage on your iPhone to set up Watch sync.",
),
WatchPresentationState::Error => (
"Codes Unavailable",
"Open IronStorage on your iPhone and share the entries again.",
),
};
Ok(WatchPresentation {
state: self.presentation_state,
title: title.to_owned(),
detail: detail.to_owned(),
records,
})
}
pub fn sync_started(&mut self) {
if self.receiver.current().is_none() && self.protected_data_available {
self.presentation_state = WatchPresentationState::Syncing;
}
}
pub fn sync_finished(&mut self) {
if self.receiver.current().is_none()
&& self.protected_data_available
&& self.presentation_state == WatchPresentationState::Syncing
{
self.presentation_state = WatchPresentationState::Empty;
}
}
pub fn sync_unavailable(&mut self) {
if self.receiver.current().is_none() && self.protected_data_available {
self.presentation_state = WatchPresentationState::Unavailable;
}
}
pub fn sync_failed(&mut self) {
self.receiver.clear_secrets();
self.presentation_state = WatchPresentationState::Error;
}
pub fn protected_data_unavailable(&mut self) {
self.receiver.clear_secrets();
self.protected_data_available = false;
self.presentation_state = WatchPresentationState::Locked;
}
pub fn no_persisted_snapshot(&mut self) {
self.receiver.revoke();
self.protected_data_available = true;
self.presentation_state = WatchPresentationState::Empty;
}
}

View File

@@ -4,8 +4,8 @@ use std::fs;
use ironstorage::{
mobile_watch::{
MobileWatchSnapshotState, WatchPersistenceAction, WatchRuntime, WatchSnapshotApply,
WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime,
WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
},
otp::OtpAlgorithm,
repository::{EntryPath, SecretBytes},
@@ -184,6 +184,22 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
)?;
let mut runtime = WatchRuntime::default();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Syncing
);
runtime.sync_finished();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.sync_started();
runtime.sync_unavailable();
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Unavailable
);
runtime.sync_started();
let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(update.apply(), WatchSnapshotApply::Replaced);
assert_eq!(update.persistence(), WatchPersistenceAction::Replace);
@@ -196,9 +212,16 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
assert_eq!(records[0].code().expose(), b"94287082");
assert_eq!(records[0].valid_until(), 60);
assert_eq!(records[0].remaining_at(59), 1);
let presentation = runtime.presentation_at(59)?;
assert_eq!(presentation.state(), WatchPresentationState::Ready);
assert_eq!(presentation.records()[0].remaining(), 1);
runtime.protected_data_unavailable();
assert!(runtime.records_at(59).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Locked
);
let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(restored.persistence(), WatchPersistenceAction::Replace);
@@ -209,5 +232,19 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked);
assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete);
assert!(runtime.records_at(59)?.is_empty());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Empty
);
runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
);
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Error
);
Ok(())
}

View File

@@ -10,7 +10,8 @@ use std::{
};
use ironstorage::mobile_watch::{
WatchPersistenceAction as StoragePersistenceAction, WatchRuntime as StorageWatchRuntime,
WatchPersistenceAction as StoragePersistenceAction, WatchPresentation as StoragePresentation,
WatchPresentationState as StoragePresentationState, WatchRuntime as StorageWatchRuntime,
WatchSnapshotApply as StorageSnapshotApply, WatchSnapshotError as StorageWatchError,
WatchSnapshotUpdate as StorageSnapshotUpdate, WatchTotpRecord as StorageTotpRecord,
};
@@ -83,10 +84,11 @@ pub struct WatchTotpRecord {
pub code: String,
pub period: u64,
pub valid_until: u64,
pub remaining: u64,
}
impl From<StorageTotpRecord> for WatchTotpRecord {
fn from(value: StorageTotpRecord) -> Self {
impl From<&StorageTotpRecord> for WatchTotpRecord {
fn from(value: &StorageTotpRecord) -> Self {
Self {
path: value.path().to_owned(),
issuer: value.issuer().map(str::to_owned),
@@ -95,6 +97,51 @@ impl From<StorageTotpRecord> for WatchTotpRecord {
.expect("storage-generated TOTP codes are ASCII"),
period: value.period(),
valid_until: value.valid_until(),
remaining: value.remaining(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum WatchPresentationState {
Ready,
Empty,
Syncing,
Stale,
Locked,
Unavailable,
Error,
}
impl From<StoragePresentationState> for WatchPresentationState {
fn from(value: StoragePresentationState) -> Self {
match value {
StoragePresentationState::Ready => Self::Ready,
StoragePresentationState::Empty => Self::Empty,
StoragePresentationState::Syncing => Self::Syncing,
StoragePresentationState::Stale => Self::Stale,
StoragePresentationState::Locked => Self::Locked,
StoragePresentationState::Unavailable => Self::Unavailable,
StoragePresentationState::Error => Self::Error,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
pub struct WatchPresentation {
pub state: WatchPresentationState,
pub title: String,
pub detail: String,
pub records: Vec<WatchTotpRecord>,
}
impl From<StoragePresentation> for WatchPresentation {
fn from(value: StoragePresentation) -> Self {
Self {
state: value.state().into(),
title: value.title().to_owned(),
detail: value.detail().to_owned(),
records: value.records().iter().map(WatchTotpRecord::from).collect(),
}
}
}
@@ -138,15 +185,44 @@ impl WatchCore {
.map_err(Into::into)
}
pub fn records_at(&self, unix_seconds: u64) -> Result<Vec<WatchTotpRecord>, WatchFfiError> {
pub fn presentation_at(&self, unix_seconds: u64) -> Result<WatchPresentation, WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.records_at(unix_seconds)
.map(|records| records.into_iter().map(Into::into).collect())
.presentation_at(unix_seconds)
.map(Into::into)
.map_err(Into::into)
}
pub fn sync_started(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_started();
Ok(())
}
pub fn sync_finished(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_finished();
Ok(())
}
pub fn sync_unavailable(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.sync_unavailable();
Ok(())
}
pub fn sync_failed(&self) -> Result<(), WatchFfiError> {
self.runtime.lock().map_err(|_| lock_error())?.sync_failed();
Ok(())
}
pub fn protected_data_unavailable(&self) -> Result<(), WatchFfiError> {
self.runtime
.lock()
@@ -183,8 +259,16 @@ mod tests {
fn bridge_masks_records_when_protected_data_is_unavailable() {
let core = super::watch_core();
core.no_persisted_snapshot().expect("available Keychain");
assert!(core.records_at(59).expect("empty snapshot").is_empty());
assert!(
core.presentation_at(59)
.expect("empty snapshot")
.records
.is_empty()
);
core.protected_data_unavailable().expect("lock transition");
assert!(core.records_at(59).is_err());
assert_eq!(
core.presentation_at(59).expect("locked presentation").state,
super::WatchPresentationState::Locked
);
}
}