Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
icloud
src
account.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
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 thiserror::Error; /// GrandSlam's password-equivalent token. Only used to establish iCloud credentials. #[derive(Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct PasswordEquivalentToken(String); impl From<String> for PasswordEquivalentToken { fn from(value: String) -> Self { PasswordEquivalentToken(value) } } impl AsRef<str> for PasswordEquivalentToken { fn as_ref(&self) -> &str { &self.0 } } impl AppToken for PasswordEquivalentToken { const APP_TOKEN_IDENTIFIER: &'static str = "com.apple.gs.idms.pet"; } /// 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> { match self.account_info()?.get("dsPrsID")? { 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, } } 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) } pub fn service(&self, dataclass: &str) -> Option<&Dictionary> { // Delegate login returns dataclasses directly; get_account_settings // groups them under mobilemeAccountInfo (the AOSKit account bag). let services = match self.0.get("mobilemeAccountInfo") { Some(value) => value.as_dictionary()?, None => &self.0, }; 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))) } /// 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 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")), }?; delegate .remove("service-data") .and_then(Value::into_dictionary) .map(AccountSettings) .ok_or(LoginError::InvalidResponse("MobileMe service 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 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()))); 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)) )); } }