Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
2ab8bf0
Branches
Tags
main
apple-crates
icloud
src
account.rs
Add instructions for agents, and a few minor API adjustments and additions.
Dadoum
commited
2ab8bf0
at 2026-10-27 03:57:30
account.rs
Blame
History
Raw
use adi::proxy::{ADIError, ADIResult}; use grandslam::plist_request::dict_to_body; use grandslam::{AccountHTTPSession, AppToken}; use plist::{Dictionary, Value}; use plist_macros::dict; use reqwest::{Method, RequestBuilder}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use thiserror::Error; pub use grandslam::tokens::PasswordEquivalentToken; /// Numeric account ID returned by iCloud, distinct from GrandSlam's alternate ID. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct DSID(String); impl From<String> for DSID { fn from(value: String) -> Self { DSID(value) } } impl AsRef<str> for DSID { fn as_ref(&self) -> &str { &self.0 } } #[derive(Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct MmeAuthToken(String); impl From<String> for MmeAuthToken { fn from(value: String) -> Self { MmeAuthToken(value) } } impl AsRef<str> for MmeAuthToken { fn as_ref(&self) -> &str { &self.0 } } #[derive(Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct CloudKitToken(String); impl From<String> for CloudKitToken { fn from(value: String) -> Self { CloudKitToken(value) } } impl AsRef<str> for CloudKitToken { fn as_ref(&self) -> &str { &self.0 } } /// iCloud's service data. Extract only the credentials needed by each service. /// Deliberately has no Debug implementation: the dictionary contains credentials. #[derive(Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct AccountSettings(Dictionary); impl AccountSettings { pub fn ds_id(&self) -> Option<DSID> { // The setup-assistant login carries it beside the delegates, not in // the account info; `parse_login_response` keeps it as `dsid`. let found = match self.account_info().and_then(|info| info.get("dsPrsID")) { Some(found) => found, None => self.0.get("dsid")?, }; match found { Value::String(ds_id) if !ds_id.is_empty() && ds_id.bytes().all(|byte| byte.is_ascii_digit()) => { Some(DSID(ds_id.clone())) } Value::Integer(ds_id) => Some(DSID(ds_id.as_unsigned()?.to_string())), _ => None, } } /// The keys and value types, never the values: what to log when an /// expected entry is missing (the values include tokens). pub fn shape(&self) -> String { fn walk(d: &Dictionary, depth: usize, out: &mut String) { for (k, v) in d { out.push_str(&format!("{}{k}: ", " ".repeat(depth))); match v { Value::Dictionary(inner) if depth < 4 => { out.push_str("dict\n"); walk(inner, depth + 1, out); } Value::Dictionary(_) => out.push_str("dict ...\n"), Value::Array(a) => out.push_str(&format!("array[{}]\n", a.len())), Value::String(_) => out.push_str("string\n"), Value::Integer(_) => out.push_str("integer\n"), Value::Boolean(_) => out.push_str("bool\n"), Value::Data(_) => out.push_str("data\n"), _ => out.push_str("other\n"), } } } let mut out = String::new(); walk(&self.0, 0, &mut out); out } pub fn account_info(&self) -> Option<&Dictionary> { self.0.get("appleAccountInfo")?.as_dictionary() } pub fn mme_token(&self) -> Option<MmeAuthToken> { self.token("mmeAuthToken").map(MmeAuthToken::from) } pub fn cloudkit_token(&self) -> Option<CloudKitToken> { self.token("cloudKitToken").map(CloudKitToken::from) } /// Every token the login returned, by name (`mmeAuthToken`, `cloudKitToken`…). pub fn tokens(&self) -> BTreeMap<String, String> { self.0 .get("tokens") .and_then(Value::as_dictionary) .into_iter() .flatten() .filter_map(|(name, value)| Some((name.clone(), value.as_string()?.to_owned()))) .filter(|(_, value)| !value.is_empty()) .collect() } /// Each provisioned `com.apple.Dataclass.*` with its string properties, such as `url`. pub fn dataclasses(&self) -> BTreeMap<String, BTreeMap<String, String>> { let mut dataclasses = BTreeMap::new(); let bags = ["mobilemeAccountInfo", "com.apple.mobileme"] .iter() .filter_map(|bag| self.0.get(*bag)?.as_dictionary()) .chain(std::iter::once(&self.0)); for bag in bags { for (name, properties) in bag { let Some(properties) = properties.as_dictionary() else { continue; }; if !name.starts_with("com.apple.Dataclass.") || dataclasses.contains_key(name) { continue; } let properties = properties .iter() .filter_map(|(key, value)| Some((key.clone(), value.as_string()?.to_owned()))) .collect(); dataclasses.insert(name.clone(), properties); } } dataclasses } pub fn service(&self, dataclass: &str) -> Option<&Dictionary> { // Delegate login returns dataclasses directly; get_account_settings // groups them under mobilemeAccountInfo (the AOSKit account bag), or // (measured 26 Oct 2026) under com.apple.mobileme. ["mobilemeAccountInfo", "com.apple.mobileme"] .iter() .filter_map(|bag| self.0.get(*bag)?.as_dictionary()) .chain(std::iter::once(&self.0)) .find_map(|services| services.get(dataclass)?.as_dictionary()) } fn token(&self, name: &str) -> Option<String> { self.0 .get("tokens")? .as_dictionary()? .get(name)? .as_string() .filter(|value| !value.is_empty()) .map(String::from) } } #[derive(Debug, Error)] pub enum LoginError { #[error("Cannot generate device authentication data: {0}")] Anisette(#[from] ADIError), #[error("iCloud request failed: {0}")] Network(#[from] reqwest::Error), #[error("Cannot parse the iCloud response: {0}")] Parsing(#[from] plist::Error), #[error("iCloud rejected authentication (status {0})")] Status(i64), #[error("iCloud rejected authentication: {0}")] Rejected(String), #[error("iCloud rejected the MobileMe delegate (status {0})")] DelegateStatus(i64), #[error("iCloud returned an invalid or missing {0}")] InvalidResponse(&'static str), } /// Build the native MobileMe delegate request, without starting another password login. /// /// The caller can add `X-Mme-Nas-Qualify` here when supplying validation data. /// Whether a particular client/account is accepted without it must be checked live. /// This request neither registers IDS nor accepts new terms of service. pub fn login_request_builder( session: &AccountHTTPSession<'_, '_>, apple_id: &str, token: &PasswordEquivalentToken, language: &str, timezone: &str, ) -> ADIResult<RequestBuilder> { let request = dict! { "delegates": dict! { "com.apple.mobileme": Dictionary::new() }, "protocolVersion": "1.0", "userInfo": dict! { "client-id": session.http_session.device().device_uuid.as_str(), "language": language, "timezone": timezone, }, }; Ok(session .anisette_request_builder( Method::POST, "https://setup.icloud.com/setup/signin/v2/login", )? .header("X-Apple-ADSID", session.alt_dsid.as_ref()) .header("Content-Type", "text/plist") .header("Accept", "application/x-plist") .basic_auth(apple_id.trim(), Some(token.as_ref())) .body(dict_to_body(request))) } /// The delegate login an iPhone's setup flow (and iCloudHelper) sends: /// `setup/iosbuddy/loginDelegates`, with the Apple ID and the PET in the body as /// well as in Basic authentication. Answered like `login_request_builder`'s /// request, so `login` finishes it. pub fn login_delegates_request_builder( session: &AccountHTTPSession<'_, '_>, apple_id: &str, token: &PasswordEquivalentToken, ) -> ADIResult<RequestBuilder> { let request = dict! { "apple-id": apple_id.trim(), "client-id": session.http_session.device().device_uuid.as_str(), "delegates": dict! { "com.apple.mobileme": Dictionary::new() }, "password": token.as_ref(), }; Ok(session .anisette_request_builder( Method::POST, "https://setup.icloud.com/setup/iosbuddy/loginDelegates", )? .header("X-Apple-ADSID", session.alt_dsid.as_ref()) .header("Content-Type", "text/plist") .header("Accept", "application/x-plist") .basic_auth(apple_id.trim(), Some(token.as_ref())) .body(dict_to_body(request))) } /// Finish a request made by `login_request_builder`, checking both status levels. pub async fn login(request: RequestBuilder) -> Result<AccountSettings, LoginError> { let response = request.send().await?.error_for_status()?.bytes().await?; parse_login_response(&response) } pub fn parse_login_response(response: &[u8]) -> Result<AccountSettings, LoginError> { let mut response: Dictionary = plist::from_bytes(response)?; match ( response.get("localizedError").and_then(Value::as_string), response.get("status").and_then(Value::as_signed_integer), ) { (Some(error), _) => Err(LoginError::Rejected(error.to_string())), (None, Some(0)) => Ok(()), (None, Some(status)) => Err(LoginError::Status(status)), (None, None) => Err(LoginError::InvalidResponse("status")), }?; let top_dsid = response.remove("dsid"); let mut delegate = response .remove("delegates") .and_then(Value::into_dictionary) .ok_or(LoginError::InvalidResponse("delegates"))? .remove("com.apple.mobileme") .and_then(Value::into_dictionary) .ok_or(LoginError::InvalidResponse("MobileMe delegate"))?; match delegate.get("status").and_then(Value::as_signed_integer) { Some(0) => Ok(()), Some(status) => Err(LoginError::DelegateStatus(status)), None => Err(LoginError::InvalidResponse("MobileMe delegate status")), }?; let mut data = delegate .remove("service-data") .and_then(Value::into_dictionary) .ok_or(LoginError::InvalidResponse("MobileMe service data"))?; if let Some(dsid) = top_dsid && !data.contains_key("dsid") { data.insert("dsid".into(), dsid); } Ok(AccountSettings(data)) } #[derive(Debug, Error)] pub enum AccountSettingsError { #[error("Cannot generate device authentication data: {0}")] Anisette(#[from] ADIError), #[error("Cannot retrieve iCloud account settings: {0}")] Network(#[from] reqwest::Error), #[error("Cannot parse iCloud account settings: {0}")] Parsing(#[from] plist::Error), #[error("iCloud account settings contain no numeric account ID")] MissingAccountID, #[error("iCloud returned settings for a different account")] AccountMismatch, } pub struct ICloudSession<'lt, 'adi> { pub http_session: AccountHTTPSession<'lt, 'adi>, pub ds_id: DSID, pub token: MmeAuthToken, } impl<'lt, 'adi> ICloudSession<'lt, 'adi> { pub fn new( http_session: AccountHTTPSession<'lt, 'adi>, ds_id: DSID, token: MmeAuthToken, ) -> Self { ICloudSession { http_session, ds_id, token, } } pub fn simple_request_builder(&self, method: Method, url: &str) -> RequestBuilder { self.http_session.simple_request_builder(method, url) } pub fn anisette_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> { self.http_session.anisette_request_builder(method, url) } pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> { self.http_session.account_request_builder(method, url) } pub fn icloud_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> { Ok(self .anisette_request_builder(method, url)? .header("X-Apple-ADSID", self.http_session.alt_dsid.as_ref()) .basic_auth(self.ds_id.as_ref(), Some(self.token.as_ref()))) } pub async fn account_settings(&self) -> Result<AccountSettings, AccountSettingsError> { let response = self .icloud_request_builder( Method::POST, "https://setup.icloud.com/setup/get_account_settings", )? .header("X-Aos-Accept-Tos", "false") .send() .await? .error_for_status()? .bytes() .await?; let settings: AccountSettings = plist::from_bytes(&response)?; match settings.ds_id() { None => Err(AccountSettingsError::MissingAccountID), Some(ds_id) if ds_id != self.ds_id => Err(AccountSettingsError::AccountMismatch), Some(_) => Ok(settings), } } } #[cfg(test)] mod tests { use super::*; #[test] fn dataclasses_come_from_every_settings_bag() { let settings = AccountSettings(dict! { "com.apple.Dataclass.Contacts": dict! { "url": "https://p1-contacts.icloud.com:443", "port": 1 }, "mobilemeAccountInfo": dict! { "com.apple.Dataclass.Calendars": dict! { "url": "https://p1-caldav.icloud.com:443" }, "com.apple.Dataclass.Contacts": dict! { "url": "https://p2-contacts.icloud.com:443" }, }, "appleAccountInfo": dict! { "dsPrsID": 1234 }, }); let dataclasses = settings.dataclasses(); assert_eq!(dataclasses.len(), 2); // The AOSKit bag wins, as in `service`; non-string properties are left out. assert_eq!( dataclasses["com.apple.Dataclass.Contacts"] .iter() .collect::<Vec<_>>(), [( &"url".to_string(), &"https://p2-contacts.icloud.com:443".to_string() )] ); } #[test] fn login_response_yields_the_service_data() { let response = |status: i64, delegate_status: i64| { let response = dict! { "status": status, "delegates": dict! { "com.apple.mobileme": dict! { "status": delegate_status, "service-data": dict! { "appleAccountInfo": dict! { "dsPrsID": 1234 }, "tokens": dict! { "mmeAuthToken": "token", "cloudKitToken": "" }, }, }, }, }; let mut xml = Vec::new(); plist::to_writer_xml(&mut xml, &response).unwrap(); xml }; let settings = parse_login_response(&response(0, 0)).unwrap(); assert_eq!(settings.ds_id(), Some(DSID("1234".to_string()))); // Empty tokens are not tokens. assert_eq!( settings.tokens().into_iter().collect::<Vec<_>>(), [("mmeAuthToken".to_string(), "token".to_string())] ); assert_eq!(settings.mme_token().unwrap().as_ref(), "token"); assert!(settings.cloudkit_token().is_none()); assert!(matches!( parse_login_response(&response(1, 0)), Err(LoginError::Status(1)) )); assert!(matches!( parse_login_response(&response(0, 2)), Err(LoginError::DelegateStatus(2)) )); } }