Harden Apple Watch snapshot synchronization

This commit is contained in:
2026-08-17 18:06:09 +02:00
parent 5c88d3ace0
commit 2a59803b6c
8 changed files with 372 additions and 64 deletions

View File

@@ -10,6 +10,8 @@ use data_encoding::HEXLOWER;
#[cfg(feature = "full")]
use data_encoding::HEXLOWER_PERMISSIVE;
#[cfg(feature = "full")]
use rand::{RngCore as _, rngs::OsRng};
#[cfg(feature = "full")]
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
@@ -221,30 +223,17 @@ struct AcceptedSnapshot {
impl WatchSnapshotReceiver {
pub fn apply(&mut self, bytes: SecretBytes) -> Result<WatchSnapshotApply, WatchSnapshotError> {
let snapshot = match decode_snapshot(bytes.expose()) {
Ok(snapshot) => snapshot,
Err(error) => {
self.current = None;
return Err(error);
}
};
let mut pairing_changed = false;
// A valid revocation can only remove secrets, so it must survive a sender reset.
if !snapshot.is_revocation()
&& let Some(accepted) = self.accepted
{
if snapshot.revision < accepted.revision {
let snapshot = decode_snapshot(bytes.expose())?;
if let Some(accepted) = self.accepted {
if snapshot.pairing != accepted.pairing || snapshot.revision < accepted.revision {
return Ok(WatchSnapshotApply::Stale);
} else if snapshot.revision == accepted.revision {
if snapshot.pairing != accepted.pairing || snapshot.digest != accepted.digest {
self.current = None;
if snapshot.digest != accepted.digest {
return Err(WatchSnapshotError::RevisionConflict);
}
if self.current.is_some() {
return Ok(WatchSnapshotApply::Duplicate);
}
} else if accepted.pairing != snapshot.pairing {
pairing_changed = true;
}
}
let accepted = AcceptedSnapshot {
@@ -252,6 +241,37 @@ impl WatchSnapshotReceiver {
revision: snapshot.revision,
digest: snapshot.digest,
};
let result = if snapshot.is_revocation() {
WatchSnapshotApply::Revoked
} else {
WatchSnapshotApply::Replaced
};
self.accepted = Some(accepted);
self.current = Some(snapshot);
Ok(result)
}
pub fn replace_authoritative(
&mut self,
bytes: SecretBytes,
) -> Result<WatchSnapshotApply, WatchSnapshotError> {
let snapshot = decode_snapshot(bytes.expose())?;
let pairing_changed = self
.accepted
.is_some_and(|accepted| accepted.pairing != snapshot.pairing);
if let Some(accepted) = self.accepted
&& !pairing_changed
{
if snapshot.revision < accepted.revision {
return Ok(WatchSnapshotApply::Stale);
}
if accepted.revision == snapshot.revision
&& accepted.digest == snapshot.digest
&& self.current.is_some()
{
return Ok(WatchSnapshotApply::Duplicate);
}
}
let result = if snapshot.is_revocation() {
WatchSnapshotApply::Revoked
} else if pairing_changed {
@@ -259,7 +279,11 @@ impl WatchSnapshotReceiver {
} else {
WatchSnapshotApply::Replaced
};
self.accepted = Some(accepted);
self.accepted = Some(AcceptedSnapshot {
pairing: snapshot.pairing,
revision: snapshot.revision,
digest: snapshot.digest,
});
self.current = Some(snapshot);
Ok(result)
}
@@ -432,14 +456,36 @@ impl WatchRuntime {
let apply = match self.receiver.apply(SecretBytes::new(bytes)) {
Ok(apply) => apply,
Err(error) => {
self.presentation_state = WatchPresentationState::Error;
self.sync_failed();
return Err(error);
}
};
Ok(self.finish_apply(apply))
}
pub fn apply_authoritative_snapshot(
&mut self,
bytes: Vec<u8>,
) -> Result<WatchSnapshotUpdate, WatchSnapshotError> {
let apply = match self.receiver.replace_authoritative(SecretBytes::new(bytes)) {
Ok(apply) => apply,
Err(error) => {
self.sync_failed();
return Err(error);
}
};
Ok(self.finish_apply(apply))
}
fn finish_apply(&mut self, apply: WatchSnapshotApply) -> WatchSnapshotUpdate {
self.protected_data_available = true;
self.presentation_state = match apply {
WatchSnapshotApply::Revoked => WatchPresentationState::Empty,
WatchSnapshotApply::Stale => WatchPresentationState::Stale,
WatchSnapshotApply::Stale => match self.receiver.current() {
Some(snapshot) if snapshot.is_revocation() => WatchPresentationState::Empty,
Some(_) => WatchPresentationState::Ready,
None => WatchPresentationState::Stale,
},
WatchSnapshotApply::Replaced
| WatchSnapshotApply::Duplicate
| WatchSnapshotApply::PairingChanged => WatchPresentationState::Ready,
@@ -454,7 +500,7 @@ impl WatchRuntime {
}
};
let current = self.receiver.current();
Ok(WatchSnapshotUpdate {
WatchSnapshotUpdate {
apply,
persistence,
revision: current.map(WatchSnapshot::revision),
@@ -462,7 +508,7 @@ impl WatchRuntime {
.map(|snapshot| u32::try_from(snapshot.entries().len()).unwrap_or(u32::MAX))
.unwrap_or(0),
receipt: self.receiver.current_receipt().unwrap_or_default(),
})
}
}
pub fn records_at(
@@ -573,8 +619,11 @@ impl WatchRuntime {
}
pub fn sync_failed(&mut self) {
self.receiver.clear_secrets();
self.presentation_state = WatchPresentationState::Error;
self.presentation_state = if self.receiver.current().is_some() {
WatchPresentationState::Stale
} else {
WatchPresentationState::Error
};
}
pub fn protected_data_unavailable(&mut self) {
@@ -620,6 +669,8 @@ impl WatchSnapshotSender {
) -> Result<(), WatchSnapshotError> {
if unpaired {
self.journal.pairing = None;
self.journal.generation = None;
self.journal.revision = 0;
self.journal.digest = None;
self.journal.snapshot_digest = None;
self.journal.delivered_revision = None;
@@ -646,29 +697,44 @@ impl WatchSnapshotSender {
if platform_pairing_identity.trim().is_empty() {
return Err(WatchSnapshotError::InvalidPairing);
}
let pairing = digest(platform_pairing_identity.as_bytes());
let platform_pairing = digest(platform_pairing_identity.as_bytes());
let content = encode_entries(&entries)?;
let content_digest = digest(&content);
let pairing_text = HEXLOWER.encode(&pairing);
let pairing_text = HEXLOWER.encode(&platform_pairing);
let digest_text = HEXLOWER.encode(&content_digest);
if self.journal.pairing.as_deref() != Some(&pairing_text)
|| self.journal.digest.as_deref() != Some(&digest_text)
|| self.journal.generation.is_none()
{
let mut generation = [0_u8; 32];
OsRng.fill_bytes(&mut generation);
self.journal.pairing = Some(pairing_text);
self.journal.generation = Some(HEXLOWER.encode(&generation));
self.journal.revision = 0;
self.journal.digest = None;
self.journal.snapshot_digest = None;
self.journal.delivered_revision = None;
self.journal.current_revision = None;
}
if self.journal.digest.as_deref() != Some(&digest_text) {
self.journal.revision = self
.journal
.revision
.checked_add(1)
.ok_or(WatchSnapshotError::RevisionOverflow)?;
self.journal.pairing = Some(pairing_text);
self.journal.digest = Some(digest_text);
self.journal.delivered_revision = None;
self.journal.current_revision = None;
save_journal(&self.path, &self.journal)?;
}
if self.journal.revision == 0 {
self.journal.revision = 1;
save_journal(&self.path, &self.journal)?;
}
let generation = decode_hash(
self.journal
.generation
.as_deref()
.ok_or(WatchSnapshotError::InvalidJournal)?,
)?;
let pairing = snapshot_pairing(platform_pairing, generation);
let snapshot = encode_snapshot(pairing, self.journal.revision, &content)?;
let snapshot_digest = digest(snapshot.expose());
self.journal.snapshot_digest = Some(HEXLOWER.encode(&snapshot_digest));
@@ -709,12 +775,19 @@ impl WatchSnapshotSender {
receipt: &[u8],
) -> Result<MobileWatchSnapshotStatus, WatchSnapshotError> {
let receipt = decode_receipt(receipt)?;
let pairing = decode_hash(
let platform_pairing = decode_hash(
self.journal
.pairing
.as_deref()
.ok_or(WatchSnapshotError::NoPendingSnapshot)?,
)?;
let generation = decode_hash(
self.journal
.generation
.as_deref()
.ok_or(WatchSnapshotError::NoPendingSnapshot)?,
)?;
let pairing = snapshot_pairing(platform_pairing, generation);
if receipt.pairing != pairing || receipt.revision != self.journal.revision {
return Ok(self.status());
}
@@ -762,6 +835,8 @@ struct SenderJournal {
#[serde(default)]
pairing: Option<String>,
#[serde(default)]
generation: Option<String>,
#[serde(default)]
revision: u64,
#[serde(default)]
digest: Option<String>,
@@ -779,6 +854,7 @@ impl Default for SenderJournal {
Self {
version: JOURNAL_VERSION,
pairing: None,
generation: None,
revision: 0,
digest: None,
snapshot_digest: None,
@@ -1085,6 +1161,15 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), WatchSnapsho
fn digest(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[cfg(feature = "full")]
fn snapshot_pairing(platform_pairing: [u8; 32], generation: [u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"IronStorage Watch snapshot generation\0");
hasher.update(platform_pairing);
hasher.update(generation);
hasher.finalize().into()
}
#[cfg(feature = "full")]
fn decode_hash(text: &str) -> Result<[u8; 32], WatchSnapshotError> {
let bytes = HEXLOWER_PERMISSIVE

View File

@@ -62,8 +62,28 @@ fn apple_sources_preserve_the_mobile_security_boundary() {
assert!(watch.contains("SecItemCopyMatching"));
assert!(app.contains("session.sendMessage(payload"));
assert!(app.contains("session.transferUserInfo(payload)"));
assert!(app.contains("session.outstandingUserInfoTransfers"));
assert!(
app.split_once("@main")
.expect("iPhone app delegate")
.0
.contains("UIApplication.didBecomeActiveNotification")
);
assert!(watch.contains("didReceiveMessage message"));
assert!(watch.contains("didReceiveUserInfo userInfo"));
assert!(watch.contains("session.outstandingUserInfoTransfers"));
assert!(watch.contains("session.transferUserInfo(acknowledgement)"));
assert!(
watch
.split_once("func sceneBecameActive()")
.expect("Watch foreground handler")
.1
.split_once("func sceneBecameInactive()")
.expect("Watch inactive handler")
.0
.contains("receivedApplicationContext")
);
assert!(watch.contains("applyAuthoritativeSnapshot"));
}
#[test]

View File

@@ -112,6 +112,12 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR
receiver.apply(SecretBytes::new(
reset_revocation.snapshot().expose().to_vec()
))?,
WatchSnapshotApply::Stale
);
assert_eq!(
receiver.replace_authoritative(SecretBytes::new(
reset_revocation.snapshot().expose().to_vec()
))?,
WatchSnapshotApply::Revoked
);
assert!(
@@ -134,12 +140,17 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR
"paired-watch-b",
vec![entry("otp/carol", "Acme", "carol", b"third-secret")],
)?;
assert_eq!(changed_watch.revision(), 4);
assert_eq!(changed_watch.revision(), 1);
assert_eq!(
receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
assert_eq!(
receiver
.replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::PairingChanged
);
assert_eq!(receiver.current().expect("new pairing").revision(), 4);
assert_eq!(receiver.current().expect("new pairing").revision(), 1);
let mut fresh_watch = WatchSnapshotReceiver::default();
assert_eq!(
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
@@ -149,14 +160,21 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR
let mut damaged = changed_watch.snapshot().expose().to_vec();
damaged[20] ^= 0x55;
assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err());
assert!(fresh_watch.current().is_none());
assert_eq!(
fresh_watch
.current()
.expect("last valid snapshot")
.entries()[0]
.account(),
"carol"
);
assert_eq!(
fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Stale
);
assert_eq!(
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::Replaced
WatchSnapshotApply::Duplicate
);
let persisted = fs::read_to_string(journal)?;
@@ -193,6 +211,131 @@ fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult {
Ok(())
}
#[test]
fn journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult {
let directory = tempfile::tempdir()?;
let journal = directory.path().join("watch-snapshot.toml");
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
let legacy = fs::read_to_string(&journal)?
.lines()
.filter(|line| !line.starts_with("generation = "))
.collect::<Vec<_>>()
.join("\n")
.replace("revision = 1", "revision = 42");
fs::write(&journal, legacy)?;
let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
assert_eq!(reset.revision(), 1);
let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?;
assert_eq!(reloaded.revision(), 1);
assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose());
Ok(())
}
#[test]
fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult {
let directory = tempfile::tempdir()?;
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
let transfer = sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"secret")],
)?;
let persisted_snapshot = transfer.snapshot().expose().to_vec();
let mut interrupted_runtime = WatchRuntime::default();
let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?;
assert_eq!(applied.persistence(), WatchPersistenceAction::Replace);
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
let mut restarted_runtime = WatchRuntime::default();
restarted_runtime.apply_snapshot(persisted_snapshot.clone())?;
let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?;
assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate);
assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep);
assert_eq!(
sender.acknowledge(redelivered.receipt())?.state(),
MobileWatchSnapshotState::Current
);
Ok(())
}
#[test]
fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult {
let directory = tempfile::tempdir()?;
let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml"));
old_sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice", b"old-secret")],
)?;
for revision in 2..5 {
old_sender.prepare(
"paired-watch",
vec![entry(
"otp/alice",
"Acme",
&format!("alice-{revision}"),
b"old-secret",
)],
)?;
}
let old = old_sender.prepare(
"paired-watch",
vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")],
)?;
assert_eq!(old.revision(), 5);
let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml"));
let new = new_sender.prepare(
"paired-watch",
vec![entry("otp/bob", "Acme", "bob", b"new-secret")],
)?;
assert_eq!(new.revision(), 1);
let mut runtime = WatchRuntime::default();
runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale);
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Ready
);
assert_eq!(runtime.records_at(59)?[0].account(), "alice-5");
let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged);
assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace);
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
assert_eq!(
new_sender.acknowledge(replaced.receipt())?.state(),
MobileWatchSnapshotState::Current
);
let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale);
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
let newer = new_sender.prepare(
"paired-watch",
vec![entry("otp/carol", "Acme", "carol", b"newer-secret")],
)?;
runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?;
let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale);
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
);
runtime.sync_unavailable();
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
Ok(())
}
#[test]
fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult {
let directory = tempfile::tempdir()?;
@@ -266,12 +409,12 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T
runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Stale
WatchPresentationState::Empty
);
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Error
WatchPresentationState::Stale
);
Ok(())
}

View File

@@ -185,6 +185,18 @@ impl WatchCore {
.map_err(Into::into)
}
pub fn apply_authoritative_snapshot(
&self,
snapshot: Vec<u8>,
) -> Result<WatchSnapshotUpdate, WatchFfiError> {
self.runtime
.lock()
.map_err(|_| lock_error())?
.apply_authoritative_snapshot(snapshot)
.map(Into::into)
.map_err(Into::into)
}
pub fn presentation_at(&self, unix_seconds: u64) -> Result<WatchPresentation, WatchFfiError> {
self.runtime
.lock()