diff --git a/src/auth.rs b/src/auth.rs index a92f03f..6c40c86 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -114,17 +114,26 @@ impl TokenProvider { }) } - pub async fn set_mme_delegate(&self, delegate: MobileMeDelegateResponse) { + // `refreshed` is when this delegate was actually obtained. Pass `SystemTime::now()` for one + // that was just logged in for; a delegate restored from disk must pass its original time, or + // the week-long freshness window below silently re-arms and hands out expired tokens. + pub async fn set_mme_delegate(&self, delegate: MobileMeDelegateResponse, refreshed: SystemTime) { *self.mme_delegate.lock().await = Some(delegate); - *self.mme_refreshed.lock().await = SystemTime::now(); + *self.mme_refreshed.lock().await = refreshed; } pub async fn get_storage_info(&self) -> Result { let token = self.get_mme_token("mmeAuthToken").await?; - let quota_url = self.mme_delegate.lock().await.as_ref().expect("no MMe?") - .config.get("com.apple.Dataclass.Quota").expect("No Quota?").as_dictionary().unwrap() - .get("storageInfoURL").expect("no storage info url?").as_string().unwrap().to_string(); + // `config` is `#[serde(default)]` because the iosbuddy endpoint omits it, so every lookup + // under it is genuinely optional and cannot be an `expect`. + let quota_url = self.mme_delegate.lock().await.as_ref() + .and_then(|delegate| delegate.config.get("com.apple.Dataclass.Quota")) + .and_then(|quota| quota.as_dictionary()) + .and_then(|quota| quota.get("storageInfoURL")) + .and_then(|url| url.as_string()) + .ok_or(PushError::MobileMeConfigMissing("com.apple.Dataclass.Quota storageInfoURL"))? + .to_string(); let account = self.account.lock().await; let dsid = account.spd.as_ref().unwrap().get("DsPrsId").expect("no dsid???s").as_unsigned_integer().unwrap().to_string(); @@ -176,7 +185,7 @@ impl TokenProvider { pub async fn get_mme_token(&self, token: &str) -> Result { // refresh every week - if self.mme_delegate.lock().await.is_none() || SystemTime::now().duration_since(*self.mme_refreshed.lock().await).unwrap() + if self.mme_delegate.lock().await.is_none() || SystemTime::now().duration_since(*self.mme_refreshed.lock().await).unwrap_or(Duration::MAX) > Duration::from_secs(60 * 60 * 24 * 7) { self.refresh_mme().await?; } diff --git a/src/error.rs b/src/error.rs index 9e40615..246e1c8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -152,6 +152,10 @@ pub enum PushError { ReportSpamError(u32), #[error("Token missing")] TokenMissing, + #[error("MobileMe delegate config has no {0}")] + MobileMeConfigMissing(&'static str), + #[error("Beacon ratchet has no secret; this accessory carries no ratchet state and cannot derive location keys")] + BeaconRatchetUninitialized, #[error("APS not ready! {0}")] APSNotReady(&'static str), #[error("Circle http error {0}")] diff --git a/src/findmy.rs b/src/findmy.rs index 18ebb46..79d4955 100644 --- a/src/findmy.rs +++ b/src/findmy.rs @@ -572,6 +572,10 @@ struct CommunicationId { ids: CommunicationIdIds } +// `Default` exists for consumers that assemble a `BeaconAccessory` purely to read its +// `master_record` / `naming` (the key-export path) and have no ratchet state to supply. Such a +// ratchet has an empty secret and cannot derive location keys — `get_current` rejects it rather +// than ratcheting nothing into more nothing. #[derive(Clone, Default, Serialize, Deserialize)] pub struct BeaconRatchet { index: usize, @@ -719,6 +723,11 @@ impl BeaconAccessory { } fn get_current(&mut self) -> Result)>, PushError> { + // An empty secret ratchets to another empty secret, so without this the accessory would + // quietly produce keys that decrypt nothing rather than reporting that it has no state. + if self.primary_ratchet.secret.is_empty() || self.secondary_ratchet.secret.is_empty() { + return Err(PushError::BeaconRatchetUninitialized); + } let mut primary = self.get_current_primary(); primary.extend(self.get_current_secondary()); primary.into_iter().map(|i| Ok((i.index, self.derive_ps_key(&i.secret)?))).collect() diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index e32ebc5..a9154e4 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -1038,12 +1038,26 @@ pub struct KeychainClientState { pub items: HashMap, } +// `MobileMeDelegateResponse::config` is `#[serde(default)]`, so an absent dataclass is ordinary +// rather than exceptional — but a bare `None` at the call site is indistinguishable from an account +// that genuinely has no such service, so say which key was missing. +pub(crate) fn mme_config_url(delegate: &MobileMeDelegateResponse, dataclass: &str, key: &str) -> Option { + let url = delegate.config.get(dataclass) + .and_then(|config| config.as_dictionary()) + .and_then(|config| config.get(key)) + .and_then(|url| url.as_string()); + if url.is_none() { + warn!("MobileMe delegate config has no {dataclass} {key}; that service is unavailable for this account"); + } + url.map(str::to_string) +} + impl KeychainClientState { pub fn new(dsid: String, adsid: String, delegate: &MobileMeDelegateResponse) -> Option { Some(KeychainClientState { dsid, adsid, - host: delegate.config.get("com.apple.Dataclass.KeychainSync")?.as_dictionary().unwrap().get("escrowProxyUrl")?.as_string().unwrap().to_string(), + host: mme_config_url(delegate, "com.apple.Dataclass.KeychainSync", "escrowProxyUrl")?, state_token: None, state: HashMap::new(), user_identity: None, diff --git a/src/sharedstreams.rs b/src/sharedstreams.rs index c1b3651..42999d2 100644 --- a/src/sharedstreams.rs +++ b/src/sharedstreams.rs @@ -28,7 +28,7 @@ impl SharedStreamsState { pub fn new(dsid: String, delegate: &MobileMeDelegateResponse) -> Option { Some(SharedStreamsState { dsid, - host: delegate.config.get("com.apple.Dataclass.SharedStreams")?.as_dictionary().unwrap().get("url")?.as_string().unwrap().to_string(), + host: crate::icloud::keychain::mme_config_url(delegate, "com.apple.Dataclass.SharedStreams", "url")?, albums: vec![], }) }