Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
2ab8bf0
Branches
Tags
main
apple-crates
grandslam
src
grandslam
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 super::app::{AppToken, RawToken, Token, TokenBag}; use crate::grandslam::{ContinuationToken, build_client_provided_data}; use crate::http_session::{AnisetteHTTPSession, AppleError, parse_status}; use crate::plist_request::dict_to_body; use adi::proxy::{ADIError, ADIResult}; use aes::Aes256; use aes::cipher::consts::U16; use aes_gcm::aead::{Aead, Payload}; use aes_gcm::{AesGcm, KeyInit}; use base64::Engine; use base64::prelude::BASE64_STANDARD; use hmac::{Hmac, Mac}; use log::warn; use plist::{Dictionary, Value}; use plist_macros::{array, dict}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::{Method, RequestBuilder}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use thiserror::Error; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct AltDsid(pub(super) String); impl AltDsid { /// Read the account identifier without requiring any tokens. pub fn from_response_headers( headers: &HeaderMap, ) -> Result<Option<AltDsid>, ResponseTokenError> { Ok(header_text(headers, "x-apple-alternate-id")?.map(AltDsid)) } } impl AsRef<str> for AltDsid { fn as_ref(&self) -> &str { &self.0 } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct IdmsToken(pub(super) String); impl IdmsToken { /// Read the identity token without requiring app tokens or an account identifier. pub fn from_response_headers( headers: &HeaderMap, ) -> Result<Option<IdmsToken>, ResponseTokenError> { let name = "x-apple-identity-token"; let token = single_header(headers, name)? .map(|value| header_token(value, name)) .transpose()?; Ok(token.map(|(_, token)| IdmsToken(token.token))) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct SessionKey(Vec<u8>); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct AuthCookie(Vec<u8>); /// Credentials used together by GrandSlam's `apptokens` operation. /// Kept separate from service tokens and the continuation token; never logged. #[derive(Clone, Serialize, Deserialize)] pub struct MasterToken { idms_token: IdmsToken, session_key: SessionKey, cookie: AuthCookie, } impl MasterToken { pub fn new(idms_token: IdmsToken, session_key: SessionKey, cookie: AuthCookie) -> Self { Self { idms_token, session_key, cookie, } } /// Web authentication replaces all three credentials together. pub fn from_response_headers(headers: &HeaderMap) -> Result<Self, ResponseTokenError> { let decode = |name| { header_text(headers, name)? .and_then(|value| BASE64_STANDARD.decode(value).ok()) .ok_or(ResponseTokenError::InvalidHeader(name)) }; let idms_token = IdmsToken::from_response_headers(headers)? .ok_or(ResponseTokenError::InvalidHeader("x-apple-identity-token"))?; let session_key = decode("x-apple-session-key")?; let cookie = decode("x-apple-encrypted-session-key")?; if session_key.len() != 32 { Err(ResponseTokenError::InvalidHeader("x-apple-session-key")) } else if cookie.is_empty() { Err(ResponseTokenError::InvalidHeader( "x-apple-encrypted-session-key", )) } else { Ok(MasterToken { idms_token, session_key: SessionKey(session_key), cookie: AuthCookie(cookie), }) } } } #[derive(Debug, Clone, Deserialize)] #[serde(transparent)] pub struct ServerProvidedData(pub(super) Dictionary); impl ServerProvidedData { pub fn alt_dsid(&self) -> Option<AltDsid> { Some(AltDsid(self.0.get("adsid")?.as_string()?.to_string())) } pub fn idms_token(&self) -> Option<IdmsToken> { Some(IdmsToken( self.0.get("GsIdmsToken")?.as_string()?.to_string(), )) } pub fn session_key(&self) -> Option<SessionKey> { Some(SessionKey(self.0.get("sk")?.as_data()?.to_vec())) } pub fn cookie(&self) -> Option<AuthCookie> { Some(AuthCookie(self.0.get("c")?.as_data()?.to_vec())) } pub fn master_token(&self) -> Option<MasterToken> { let token = MasterToken { idms_token: self.idms_token()?, session_key: self.session_key()?, cookie: self.cookie()?, }; let valid = !token.idms_token.0.is_empty() && token.session_key.0.len() == 32 && !token.cookie.0.is_empty(); valid.then_some(token) } pub fn continuation_token(&self) -> Option<ContinuationToken> { Some(ContinuationToken( self.0.get("ck")?.as_string()?.to_string(), )) } pub fn tokens(&self) -> Option<TokenBag> { Some(TokenBag(self.0.get("t")?.as_dictionary()?.clone())) } } #[derive(Debug, Error)] pub enum AppTokenRequestError { #[error("Cannot proceed: {0}")] Apple(#[from] AppleError), #[error("Cannot generate device authentication data: {0}")] Anisette(#[from] ADIError), #[error("The provided authentication token is not valid")] InvalidAuthToken, // vvv Internal errors vvv #[error("Invalid URL bag")] InvalidURLBag, #[error("Network error: {0}")] Network(#[from] reqwest::Error), #[error("Failed to parse the app token request response: {0}")] Parsing(#[from] plist::Error), #[error("Failed to parse the app token request response")] Structure(Dictionary), #[error("Invalid token returned by the server")] InvalidResponse(Dictionary), } #[derive(Clone)] pub struct AccountHTTPSession<'lt, 'adi> { pub http_session: AnisetteHTTPSession<'lt, 'adi>, pub alt_dsid: AltDsid, } impl<'lt, 'adi> AccountHTTPSession<'lt, 'adi> { pub fn new(http_session: AnisetteHTTPSession<'lt, 'adi>, alt_dsid: AltDsid) -> Self { Self { http_session, alt_dsid, } } 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) } /// Adds Anisette and the account ID; the endpoint's credential is supplied separately. pub fn account_request_builder(&self, method: Method, url: &str) -> ADIResult<RequestBuilder> { Ok(self .anisette_request_builder(method, url)? .header("X-Apple-I-Identity-Id", &self.alt_dsid.0)) } pub async fn get_app_token<T: AppToken>( &self, master: &MasterToken, ) -> Result<Token<T>, AppTokenRequestError> { Ok(self .get_app_token_for_service(T::APP_TOKEN_IDENTIFIER, master) .await? .into()) } /// Request a token for a service identifier supplied at runtime. pub async fn get_app_token_for_service( &self, app_token_identifier: &str, master: &MasterToken, ) -> Result<RawToken, AppTokenRequestError> { let MasterToken { idms_token, session_key, cookie, } = master; let alt_dsid = self.alt_dsid.0.as_str(); let gs_service_url = self .http_session .url_bag() .get("gsService") .and_then(Value::as_string) .ok_or(AppTokenRequestError::InvalidURLBag)?; let cpd = build_client_provided_data(&self.http_session) .map_err(AppTokenRequestError::Anisette)?; let checksum = Hmac::<Sha256>::new_from_slice(&session_key.0) .map_err(|_| AppTokenRequestError::InvalidAuthToken)? .chain_update("apptokens") .chain_update(alt_dsid) .chain_update(app_token_identifier) .finalize() .into_bytes() .to_vec(); let request_plist = dict! { "Header": dict!{ "Version": "1.0.1" }, "Request": dict!{ "u": alt_dsid, "app": array![ app_token_identifier ], "c": Value::Data(cookie.0.to_vec()), "t": idms_token.0.as_str(), "checksum": Value::Data(checksum), "cpd": cpd, "o": "apptokens", } }; let response = self .anisette_request_builder(Method::POST, gs_service_url) .map_err(AppTokenRequestError::Anisette)? .header("Content-Type", "text/x-xml-plist") .body(dict_to_body(request_plist)) .send() .await .map_err(AppTokenRequestError::Network)? .error_for_status()? .bytes() .await .map_err(AppTokenRequestError::Network)?; let response_plist: Dictionary = plist::from_bytes(&response).map_err(AppTokenRequestError::Parsing)?; let response_dict = response_plist .get("Response") .and_then(Value::as_dictionary) .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?; let status = response_dict .get("Status") .and_then(Value::as_dictionary) .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?; parse_status(status)?; let encrypted_tokens = response_dict .get("et") .and_then(Value::as_data) .ok_or_else(|| AppTokenRequestError::Structure(response_plist.clone()))?; if encrypted_tokens.len() < 19 { return Err(AppTokenRequestError::InvalidResponse(response_plist)); } let associated_data = &encrypted_tokens[0..3]; if associated_data != b"XYZ" { warn!("Surprised by the associated data provided by Apple: {associated_data:02X?}"); // return Err(AppTokenRequestError::InvalidResponse); } let iv = &encrypted_tokens[3..19]; let encrypted_token = &encrypted_tokens[19..]; let tokens_data = AesGcm::<Aes256, U16>::new_from_slice(&session_key.0) .map_err(|_| AppTokenRequestError::InvalidAuthToken)? .decrypt( // iv is of fixed size. It shall not fail. iv.try_into().expect("Invalid IV size??"), Payload { msg: encrypted_token, aad: associated_data, }, ) .map_err(|_| AppTokenRequestError::InvalidResponse(response_plist.clone()))?; let tokens: Dictionary = plist::from_bytes(&tokens_data).map_err(AppTokenRequestError::Parsing)?; let tokens = ServerProvidedData(tokens); tokens .tokens() .and_then(|tokens| { tokens .0 .get(app_token_identifier) .and_then(|token| plist::from_value(token).ok()) }) .ok_or_else(|| AppTokenRequestError::InvalidResponse(tokens.0)) } } /// Get a header that must appear at most once. fn single_header<'lt>( headers: &'lt HeaderMap, name: &'static str, ) -> Result<Option<&'lt HeaderValue>, ResponseTokenError> { let mut values = headers.get_all(name).iter(); match (values.next(), values.next()) { (value, None) => Ok(value), _ => Err(ResponseTokenError::InvalidHeader(name)), } } pub(super) fn header_text( headers: &HeaderMap, name: &'static str, ) -> Result<Option<String>, ResponseTokenError> { single_header(headers, name)? .map(|value| match value.to_str() { Ok(text) if !text.is_empty() => Ok(text.to_string()), _ => Err(ResponseTokenError::InvalidHeader(name)), }) .transpose() } /// Parse a `base64(service:token:duration:issued)` header (seconds, then epoch milliseconds). pub(super) fn header_token( value: &HeaderValue, name: &'static str, ) -> Result<(String, RawToken), ResponseTokenError> { let value = BASE64_STANDARD .decode(value.as_bytes()) .map_err(|_| ResponseTokenError::InvalidHeader(name))?; let value = String::from_utf8(value).map_err(|_| ResponseTokenError::InvalidHeader(name))?; let fields: Vec<&str> = value.split(':').collect(); match fields.as_slice() { [service, token, duration, issued] if !service.is_empty() && !token.is_empty() => { let duration: u64 = duration .parse() .map_err(|_| ResponseTokenError::InvalidHeader(name))?; let issued: u64 = issued .parse() .map_err(|_| ResponseTokenError::InvalidHeader(name))?; let expiry_epoch_millis = duration .checked_mul(1000) .and_then(|duration| issued.checked_add(duration)) .ok_or(ResponseTokenError::InvalidHeader(name))?; Ok(( service.to_string(), RawToken { token: token.to_string(), duration, expiry_epoch_millis, }, )) } _ => Err(ResponseTokenError::InvalidHeader(name)), } } /// Parse a `base64(service:token:duration)` header. The password-equivalent token /// carries no issue time: it counts from its receipt. pub(super) fn pe_header_token( value: &HeaderValue, name: &'static str, ) -> Result<(String, RawToken), ResponseTokenError> { let value = BASE64_STANDARD .decode(value.as_bytes()) .map_err(|_| ResponseTokenError::InvalidHeader(name))?; let value = String::from_utf8(value).map_err(|_| ResponseTokenError::InvalidHeader(name))?; let fields: Vec<&str> = value.split(':').collect(); match fields.as_slice() { [service, token, duration] if !service.is_empty() && !token.is_empty() => { let duration: u64 = duration .parse() .map_err(|_| ResponseTokenError::InvalidHeader(name))?; let received = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|_| ResponseTokenError::InvalidHeader(name))? .as_millis() as u64; let expiry_epoch_millis = duration .checked_mul(1000) .and_then(|duration| received.checked_add(duration)) .ok_or(ResponseTokenError::InvalidHeader(name))?; Ok(( service.to_string(), RawToken { token: token.to_string(), duration, expiry_epoch_millis, }, )) } _ => Err(ResponseTokenError::InvalidHeader(name)), } } #[derive(Debug, Error)] pub enum ResponseTokenError { #[error("Invalid authentication response header: {0}")] InvalidHeader(&'static str), #[error("Duplicate service token in authentication response")] DuplicateService, } #[cfg(test)] mod tests { #[test] fn password_equivalent_token_header_counts_from_receipt() { let value = HeaderValue::from_str(&BASE64_STANDARD.encode("com.apple.gs.idms.pet:secret:300")) .unwrap(); let before = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as u64; let (service, token) = pe_header_token(&value, "x-apple-pe-token").unwrap(); assert_eq!(service, "com.apple.gs.idms.pet"); assert_eq!((token.token.as_str(), token.duration), ("secret", 300)); assert!(token.expiry_epoch_millis >= before + 300_000); for bad in ["a:b", "a:b:c", "a::300", ":b:300", "a:b:300:1"] { let value = HeaderValue::from_str(&BASE64_STANDARD.encode(bad)).unwrap(); assert!( pe_header_token(&value, "x-apple-pe-token").is_err(), "{bad}" ); } } use super::*; fn encoded(value: impl AsRef<[u8]>) -> HeaderValue { HeaderValue::from_str(&BASE64_STANDARD.encode(value)).unwrap() } #[test] fn token_headers_are_parsed() { let (service, token) = header_token(&encoded("com.apple.gs.idms.hb:token:60:1000"), "x").unwrap(); assert_eq!(service, "com.apple.gs.idms.hb"); assert_eq!( ( token.token.as_str(), token.duration, token.expiry_epoch_millis ), ("token", 60, 61000) ); for invalid in [ "service:token:60", ":token:60:1000", "service::60:1000", "service:token:sixty:1000", "service:token:60:1000:extra", ] { assert!( header_token(&encoded(invalid), "x").is_err(), "{invalid} should be rejected" ); } } #[test] fn repeated_or_empty_headers_are_rejected() { let name = "x-apple-alternate-id"; assert_eq!( AltDsid::from_response_headers(&HeaderMap::new()).unwrap(), None ); let mut headers = HeaderMap::new(); headers.append(name, HeaderValue::from_static("001234")); assert_eq!( AltDsid::from_response_headers(&headers).unwrap(), Some(AltDsid("001234".to_string())) ); headers.append(name, HeaderValue::from_static("005678")); assert!(AltDsid::from_response_headers(&headers).is_err()); let mut headers = HeaderMap::new(); headers.insert(name, HeaderValue::from_static("")); assert!(AltDsid::from_response_headers(&headers).is_err()); } #[test] fn master_token_needs_all_three_credentials() { let mut headers = HeaderMap::new(); headers.insert("x-apple-identity-token", encoded("service:idms:60:1000")); headers.insert("x-apple-session-key", encoded([7u8; 32])); assert!(matches!( MasterToken::from_response_headers(&headers), Err(ResponseTokenError::InvalidHeader( "x-apple-encrypted-session-key" )) )); headers.insert("x-apple-encrypted-session-key", encoded("cookie")); let token = MasterToken::from_response_headers(&headers).unwrap(); assert_eq!(token.idms_token, IdmsToken("idms".to_string())); assert_eq!(token.session_key.0, [7u8; 32]); } }