Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
grandslam
src
grandslam.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
grandslam.rs
Blame
History
Raw
mod account; mod anisette; mod app; mod heartbeat; mod identity; mod secondary_actions; mod url_switch; use crate::bundle_information::BundleInformation; use crate::device::Device; use crate::http_session::{ AnisetteHTTPSession, AppleError, BasicHTTPSession, HTTPSession, HTTPSessionCreationError, URLBag, URLBagError, parse_status, }; use crate::plist_request::dict_to_body; pub use account::*; use adi::proxy::{ADIError, ADIResult}; use aes::cipher::block_padding; use aes::cipher::block_padding::Pkcs7; pub use anisette::*; pub use app::*; use base64::Engine; use base64::prelude::BASE64_STANDARD; use cbc::cipher::{BlockModeDecrypt, KeyIvInit}; use chrono::{Local, SecondsFormat}; pub use heartbeat::*; use hmac::{Hmac, KeyInit, Mac}; pub use identity::*; use plist::{Dictionary, Value}; use plist_macros::{array, dict}; use reqwest::{Certificate, Method}; use sha2::{Digest, Sha256}; use srp::ClientVerifier; use srp::groups::G2048; use thiserror::Error; pub use url_switch::*; const ROOT_CA: &[u8] = include_bytes!("grandslam/root-ca.pem"); /// Build an HTTP session for interacting with grandslam endpoints. /// It will connect to the default production servers, and identifies itself as the application /// described in `client_bundle_info` running on the device `device`. /// /// ```no_run /// use grandslam::bundle_information::AUTH_KIT_BUNDLE_INFORMATION; /// use grandslam::device::Device; /// /// # async fn example() -> Result<(), Box<dyn std::error::Error>> { /// let device = Device { /// device_model: "MacBookPro13,2".to_string(), /// operating_system_information: "macOS;15.6.1;24G90".to_string(), /// device_uuid: "A8B31C86-359B-4D95-8950-BA5DD8FFC46F".to_string(), /// }; /// /// let grandslam_http_session = grandslam::http_session(device, AUTH_KIT_BUNDLE_INFORMATION).await?; /// # Ok(()) /// # } /// ``` pub async fn http_session( device: Device, client_bundle_info: BundleInformation<'_>, ) -> Result<HTTPSession<'_>, HTTPSessionCreationError> { http_session_with_idms_env(device, client_bundle_info, 0).await } pub fn bag_url(idms_env: usize) -> &'static str { const URL_FOR_IDMS_ENV: [&str; 4] = [ "https://gsa.apple.com/grandslam/GsService2/lookup", // the other urls seem to be internal Apple servers. "https://grandslam-uat.apple.com/grandslam/GsService2/lookup", "https://grandslam-it.apple.com/grandslam/GsService2/lookup", "https://grandslam-it3.apple.com/grandslam/GsService2/lookup", ]; URL_FOR_IDMS_ENV[idms_env] } pub async fn http_session_with_idms_env( device: Device, client_bundle_info: BundleInformation<'_>, idms_env: usize, ) -> Result<HTTPSession<'_>, HTTPSessionCreationError> { http_session_with_custom_bag_url(device, client_bundle_info, bag_url(idms_env)).await } pub(crate) fn parse_url_bag_response(url_bag_response: &[u8]) -> Result<URLBag, URLBagError> { let mut url_bag_response: Dictionary = plist::from_bytes(url_bag_response).map_err(|_| URLBagError::Parsing)?; url_bag_response .remove("urls") .and_then(Value::into_dictionary) .ok_or(URLBagError::InvalidUrlBag) } pub async fn http_session_with_custom_bag_url<'lt>( device: Device, client_bundle_info: BundleInformation<'lt>, url: &str, ) -> Result<HTTPSession<'lt>, HTTPSessionCreationError> { let http_session = BasicHTTPSession::new( device, client_bundle_info, Some(Certificate::from_pem(ROOT_CA).expect("the bundled certificate is not valid??")), )?; let url_bag_response = http_session .request_builder(Method::GET, url) .send() .await? .error_for_status()? .bytes() .await?; let url_bag = parse_url_bag_response(&url_bag_response) .map_err(|_| HTTPSessionCreationError::InvalidURLBagResponseStructure)?; HTTPSession::new(http_session, url_bag) } #[derive(Debug)] pub enum AuthOutcome { Success(ServerProvidedData), SecondaryActionRequired(Option<ServerProvidedData>, String), AnisetteResyncRequired(Vec<u8>), AnisetteReprovisionRequired, /// Opaque `idata` to pass to `url_switch` with the original bag lookup URL. UrlSwitchingRequired(String), } #[derive(Debug, Error)] pub enum AuthError { #[error("Could not log-in to Apple servers: {0}")] Apple(#[from] AppleError), #[error("Cannot generate device authentication data: {0}")] Anisette(#[from] ADIError), #[error("Network error: {0}")] Network(#[from] reqwest::Error), // vvv Internal errors vvv #[error("Invalid URL bag")] InvalidURLBag, #[error("Cannot parse server response from step {auth_step}: {error}")] Parsing { auth_step: u8, error: plist::Error }, #[error("Invalid server response structure (censor private data before reporting): {0:?}")] Structure(Dictionary), #[error("Authentication protocol failure: {0}")] SRP(#[from] srp::AuthError), #[error("Unknown server protocol: {0}")] UnknownProtocol(String), #[error("Decryption error: {0}")] Decryption(#[from] block_padding::Error), } pub type AuthResult = Result<AuthOutcome, AuthError>; pub fn build_client_provided_data( http_session: &AnisetteHTTPSession<'_, '_>, ) -> ADIResult<Dictionary> { let adi_proxy = http_session.adi_proxy(); let device = http_session.device(); let application_information = http_session.application_information(); let client_time = Local::now() .to_utc() .to_rfc3339_opts(SecondsFormat::Secs, true); let timezone = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string()); let locale = sys_locale::get_locale() .unwrap_or_else(|| String::from("en-US")) .replace('-', "_"); let routing_info = adi_proxy.get_idms_routing(GRANDSLAM_DSID)?; let (mid, otp) = { let (mid_b, otp_b) = adi_proxy.request_otp(GRANDSLAM_DSID)?; let mid = BASE64_STANDARD.encode(mid_b); let otp = BASE64_STANDARD.encode(otp_b); (mid, otp) }; let client_provided_data = dict! { "X-Apple-I-Client-Time": client_time, "X-Apple-I-TimeZone": timezone, "X-Apple-Locale": locale.clone(), "X-Apple-I-MD": otp, "X-Apple-I-MD-M": mid, "X-Apple-I-MD-RINFO": routing_info.to_string(), "X-Mme-Device-Id": device.device_uuid.clone(), "capp": application_information.bundle_name, "icscrec": true, "loc": locale, "pbe": false, "prkgen": true, "svct": "iCloud", }; Ok(client_provided_data) } enum StatusCode { Success, SecondaryActionRequired, AnisetteReprovisionRequired, AnisetteResyncRequired, URLSwitchingRequired, } impl TryFrom<u64> for StatusCode { type Error = (); fn try_from(status_code: u64) -> Result<Self, Self::Error> { match status_code { 200 => Ok(StatusCode::Success), 409 => Ok(StatusCode::SecondaryActionRequired), 433 => Ok(StatusCode::AnisetteReprovisionRequired), 434 => Ok(StatusCode::AnisetteResyncRequired), 435 => Ok(StatusCode::URLSwitchingRequired), _ => Err(()), } } } #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct ContinuationToken(String); impl ContinuationToken { /// Read the continuation token independently of other response headers. pub fn from_response_headers( headers: &reqwest::header::HeaderMap, ) -> Result<Option<ContinuationToken>, ResponseTokenError> { Ok(account::header_text(headers, "x-apple-i-ck")?.map(ContinuationToken)) } } /// Authenticate with an Apple Account username or AltDSID and a password. /// Pass `alt_dsid.as_ref()` to use a typed `AltDsid`. pub async fn login( http_session: &AnisetteHTTPSession<'_, '_>, user: &str, password: &str, ) -> AuthResult { login_with_credential(http_session, user, LoginCredential::Password(password)).await } /// Authenticate using a previously issued continuation token instead of a password. /// /// `user` may be an Apple Account username or AltDSID (`alt_dsid.as_ref()`). /// Use the account and device identity associated with the token. The server can /// still require a secondary action or reject a revoked token; callers decide /// whether to fall back to password authentication. Extract the token from the /// returned server-provided data again after success to pick up a replacement. pub async fn login_with_continuation_token( http_session: &AnisetteHTTPSession<'_, '_>, user: &str, continuation_token: &ContinuationToken, ) -> AuthResult { login_with_credential( http_session, user, LoginCredential::ContinuationToken(continuation_token), ) .await } #[derive(Clone, Copy)] enum LoginCredential<'lt> { Password(&'lt str), ContinuationToken(&'lt ContinuationToken), } async fn login_with_credential( http_session: &AnisetteHTTPSession<'_, '_>, user: &str, credential: LoginCredential<'_>, ) -> AuthResult { let gs_service_url = http_session .url_bag() .get("gsService") .and_then(Value::as_string) .ok_or(AuthError::InvalidURLBag)?; let cpd = { let mut cpd = build_client_provided_data(http_session)?; match credential { LoginCredential::ContinuationToken(_) => { cpd.insert("ckauth".into(), true.into()); } LoginCredential::Password(_) => { cpd.insert("bootstrap".into(), true.into()); cpd.insert("ckgen".into(), true.into()); } } cpd }; // TODO: implement s4k, if some day someone needs that. let srp_client = srp::Client::<G2048, Sha256>::new_with_options(false); let a: [u8; 256] = rand::random(); let a_pub = srp_client.compute_public_ephemeral(&a); let request_plist = dict! { "Header": dict!{ "Version": "1.0.1", // "GSClientIdentifier": "AppleIDAuthSupport", }, "Request": dict!{ "A2k": Value::Data(a_pub), "cpd": cpd.clone(), "o": "init", "ps": array![ "s2k", "s2k_fo" // most Apple servers seem to not even implement that protocol anymore. ], "u": user } }; let response = http_session .anisette_request_builder(Method::POST, gs_service_url)? .header("Content-Type", "text/x-xml-plist") .body(dict_to_body(request_plist)) .send() .await? .error_for_status()? .bytes() .await?; let response_plist: Dictionary = plist::from_bytes(&response).map_err(|error| AuthError::Parsing { auth_step: 0, error, })?; let response_dict = response_plist .get("Response") .and_then(Value::as_dictionary) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let status = response_dict .get("Status") .and_then(Value::as_dictionary) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; parse_status(status)?; let iteration_count = response_dict .get("i") .and_then(Value::as_unsigned_integer) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let salt = response_dict .get("s") .and_then(Value::as_data) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let selected_protocol = response_dict .get("sp") .and_then(Value::as_string) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let cookie = response_dict .get("c") .and_then(Value::as_string) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let b = response_dict .get("B") .and_then(Value::as_data) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let secret = match credential { LoginCredential::Password(password) => password, LoginCredential::ContinuationToken(ck) => ck.0.as_str(), } .as_bytes(); let hashed_password: Vec<u8> = match selected_protocol { // SRP with a 2048/4096-bit long A. "s2k" | "s4k" => Sha256::digest(secret).to_vec(), // SRP with a 2048-bit long A + fo? "s2k_fo" => hex::encode(Sha256::digest(secret)).into_bytes(), protocol => return Err(AuthError::UnknownProtocol(protocol.to_string())), }; let processed_password = { let mut buf = [0u8; 32]; pbkdf2::pbkdf2::<Hmac<Sha256>>(&hashed_password, salt, iteration_count as u32, &mut buf) .expect("the length is wrong??"); buf }; let verifier = srp_client.process_reply(&a, user.as_bytes(), &processed_password, salt, b)?; let request_plist = dict! { "Header": dict!{ "Version": "1.0.1" }, "Request": dict!{ "M1": Value::Data(verifier.proof().to_vec()), "c": cookie, "cpd": cpd, "o": "complete", "u": user } }; let response = http_session .anisette_request_builder(Method::POST, gs_service_url)? // HACK: On success, Apple apps pass the obtained data to the app, // which will then initialize another HTTP client, with a new TLS session. // Here, it would not create a new TLS session, and I don't want to force // people to use a new HTTP session on success. So instead, I force the connection // to be interrupted here. It is important because on some networks, the apptokens // request fails if the TLS session is kept alive. .header("Connection", "close") .header("Content-Type", "text/x-xml-plist") .body(dict_to_body(request_plist)) .send() .await? .error_for_status()? .bytes() .await?; let response_plist: Dictionary = plist::from_bytes(&response).map_err(|error| AuthError::Parsing { auth_step: 1, error, })?; // trace!("Received: {:#?}", response_plist); let response_dict = response_plist .get("Response") .and_then(Value::as_dictionary) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; // println!("Response: {response_dict:?}"); let status = response_dict .get("Status") .and_then(Value::as_dictionary) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; parse_status(status)?; let status_code: StatusCode = status .get("hsc") .and_then(Value::as_unsigned_integer) .unwrap_or(0) .try_into() .map_err(|_| AuthError::Structure(response_plist.clone()))?; let server_provided_data: Option<ServerProvidedData> = response_dict .get("spd") .and_then(Value::as_data) .map(|server_provided_data| { let server_proof = response_dict .get("M2") .and_then(Value::as_data) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; decrypt_server_provided_data(&verifier, server_proof, server_provided_data) }) .transpose()?; match status_code { StatusCode::Success => server_provided_data .map(AuthOutcome::Success) .ok_or_else(|| AuthError::Structure(response_plist)), StatusCode::SecondaryActionRequired => { let action_url = status .get("au") .and_then(Value::as_string) .map(String::from) .ok_or_else(|| AuthError::Structure(response_plist))?; Ok(AuthOutcome::SecondaryActionRequired( server_provided_data, action_url, )) } StatusCode::AnisetteReprovisionRequired => Ok(AuthOutcome::AnisetteReprovisionRequired), StatusCode::AnisetteResyncRequired => { let sim = status .get("X-Apple-I-MD-DATA") .and_then(Value::as_string) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; let sim = BASE64_STANDARD .decode(sim) .map_err(|_| AuthError::Structure(response_plist.clone()))?; Ok(AuthOutcome::AnisetteResyncRequired(sim)) } StatusCode::URLSwitchingRequired => { let url_switching_data = status .get("X-Apple-I-Data") .and_then(Value::as_string) .ok_or_else(|| AuthError::Structure(response_plist.clone()))?; Ok(AuthOutcome::UrlSwitchingRequired( url_switching_data.to_string(), )) } } } /// Check the server's proof, then decrypt its data with the session key. fn decrypt_server_provided_data( verifier: &ClientVerifier<Sha256>, server_proof: &[u8], server_provided_data: &[u8], ) -> Result<ServerProvidedData, AuthError> { verifier.verify_server(server_proof)?; // The session is now verified, let's grab those tokens! let session_key = verifier.key(); // The session key here is of a known length, we know those can't fail. let extra_data_key = Hmac::<Sha256>::new_from_slice(session_key) .expect("the length is wrong??") .chain_update("extra data key:") .finalize() .into_bytes(); let extra_data_iv = Hmac::<Sha256>::new_from_slice(session_key) .expect("the length is wrong??") .chain_update("extra data iv:") .finalize() .into_bytes(); let server_provided_data = cbc::Decryptor::<aes::Aes256>::new_from_slices(&extra_data_key, &extra_data_iv[..16]) .expect("the lengths are wrong??") .decrypt_padded_vec::<Pkcs7>(server_provided_data)?; let server_provided_data: Dictionary = plist::from_bytes(&server_provided_data).map_err(|error| AuthError::Parsing { auth_step: 3, error, })?; Ok(ServerProvidedData(server_provided_data)) }