Dadoum commited on 2026-09-28 18:04:25
Showing 6 changed files, with 152 additions and 26 deletions.
| ... | ... |
@@ -31,13 +31,14 @@ return a replacement. Extract and save the token again after success to handle |
| 31 | 31 |
rotation. The library does not persist credentials or automatically fall back to |
| 32 | 32 |
a password when a token is rejected. |
| 33 | 33 |
|
| 34 |
-Secondary-action responses can issue a token in `X-Apple-I-CK`. Before consuming |
|
| 35 |
-the response body, call |
|
| 36 |
-`ContinuationToken::from_response_headers(response.headers())` to base64-decode |
|
| 37 |
-that header into a UTF-8 token. An absent header returns `Ok(None)`; malformed |
|
| 38 |
-base64, invalid UTF-8, and empty tokens return an error. The `ck` plist value is |
|
| 39 |
-already decoded and must not be decoded a second time. Tokens support serde for |
|
| 40 |
-secure storage and redact their value in `Debug` output. |
|
| 34 |
+Secondary-action response headers are separate from `ServerProvidedData`. |
|
| 35 |
+Use `TokenBag::from_response_headers` to extract app and heartbeat tokens, and |
|
| 36 |
+`ContinuationToken::from_response_headers`, `IdmsToken::from_response_headers` |
|
| 37 |
+and `AltDsid::from_response_headers` for the individual values. Missing values |
|
| 38 |
+return `Ok(None)` or an empty token bag; malformed values return `ResponseTokenError`. |
|
| 39 |
+Callers decide which values are required to complete their operation. |
|
| 40 |
+App-token expiry is calculated from the issue timestamp and duration carried |
|
| 41 |
+in the header. These helpers do not construct a GrandSlam SPD dictionary. |
|
| 41 | 42 |
|
| 42 | 43 |
These details were traced in AuthKitWin 394.2 in Ghidra: |
| 43 | 44 |
`_CopyAuthParametersForAccount` (`0x18003ca60`) selects the SRP secret and flags; |
| ... | ... |
@@ -14,10 +14,10 @@ use crate::http_session::{
|
| 14 | 14 |
URLBagError, parse_status, |
| 15 | 15 |
}; |
| 16 | 16 |
use crate::plist_request::dict_to_body; |
| 17 |
+pub use account::*; |
|
| 17 | 18 |
use adi::proxy::{ADIError, ADIResult};
|
| 18 | 19 |
use aes::cipher::block_padding; |
| 19 | 20 |
use aes::cipher::block_padding::Pkcs7; |
| 20 |
-pub use account::*; |
|
| 21 | 21 |
pub use anisette::*; |
| 22 | 22 |
pub use app::*; |
| 23 | 23 |
use base64::Engine; |
| ... | ... |
@@ -25,12 +25,11 @@ use base64::prelude::BASE64_STANDARD; |
| 25 | 25 |
use bytes::Bytes; |
| 26 | 26 |
use cbc::cipher::{BlockModeDecrypt, KeyIvInit};
|
| 27 | 27 |
use chrono::{Local, SecondsFormat};
|
| 28 |
+pub use heartbeat::*; |
|
| 28 | 29 |
use hmac::{Hmac, KeyInit, Mac};
|
| 29 |
-use log::trace; |
|
| 30 |
+pub use identity::*; |
|
| 30 | 31 |
use plist::{Dictionary, Value};
|
| 31 | 32 |
use plist_macros::{array, dict};
|
| 32 |
-pub use heartbeat::*; |
|
| 33 |
-pub use identity::*; |
|
| 34 | 33 |
use reqwest::{Certificate, Method};
|
| 35 | 34 |
use sha2::{Digest, Sha256};
|
| 36 | 35 |
use srp::groups::G2048; |
| ... | ... |
@@ -241,6 +240,15 @@ impl TryFrom<u64> for StatusCode {
|
| 241 | 240 |
#[serde(transparent)] |
| 242 | 241 |
pub struct ContinuationToken(String); |
| 243 | 242 |
|
| 243 |
+impl ContinuationToken {
|
|
| 244 |
+ /// Read the continuation token independently of other response headers. |
|
| 245 |
+ pub fn from_response_headers( |
|
| 246 |
+ headers: &reqwest::header::HeaderMap, |
|
| 247 |
+ ) -> Result<Option<ContinuationToken>, ResponseTokenError> {
|
|
| 248 |
+ Ok(account::header_text(headers, "x-apple-i-ck")?.map(ContinuationToken)) |
|
| 249 |
+ } |
|
| 250 |
+} |
|
| 251 |
+ |
|
| 244 | 252 |
/// Authenticate with an Apple Account username or AltDSID and a password. |
| 245 | 253 |
/// Pass `alt_dsid.as_ref()` to use a typed `AltDsid`. |
| 246 | 254 |
pub async fn login( |
| ... | ... |
@@ -380,9 +388,10 @@ async fn login_with_credential( |
| 380 | 388 |
.ok_or_else(|| AuthError::Structure(response_plist.clone()))?; |
| 381 | 389 |
|
| 382 | 390 |
let secret = match credential {
|
| 383 |
- LoginCredential::Password(password) => { password },
|
|
| 384 |
- LoginCredential::ContinuationToken(ck) => { ck.0.as_str() }
|
|
| 385 |
- }.as_bytes(); |
|
| 391 |
+ LoginCredential::Password(password) => password, |
|
| 392 |
+ LoginCredential::ContinuationToken(ck) => ck.0.as_str(), |
|
| 393 |
+ } |
|
| 394 |
+ .as_bytes(); |
|
| 386 | 395 |
|
| 387 | 396 |
let hashed_password: Vec<u8> = match selected_protocol {
|
| 388 | 397 |
// SRP with a 2048/4096-bit long A. |
| ... | ... |
@@ -506,8 +515,6 @@ async fn login_with_credential( |
| 506 | 515 |
error, |
| 507 | 516 |
})?; |
| 508 | 517 |
|
| 509 |
- trace!("Parsed server provided data: {:#?}", server_provided_data);
|
|
| 510 |
- |
|
| 511 | 518 |
Ok(ServerProvidedData(server_provided_data)) |
| 512 | 519 |
}) |
| 513 | 520 |
.transpose()?; |
| ... | ... |
@@ -19,6 +19,15 @@ use thiserror::Error; |
| 19 | 19 |
#[serde(transparent)] |
| 20 | 20 |
pub struct AltDsid(pub(super) String); |
| 21 | 21 |
|
| 22 |
+impl AltDsid {
|
|
| 23 |
+ /// Read the account identifier without requiring any tokens. |
|
| 24 |
+ pub fn from_response_headers( |
|
| 25 |
+ headers: &reqwest::header::HeaderMap, |
|
| 26 |
+ ) -> Result<Option<AltDsid>, ResponseTokenError> {
|
|
| 27 |
+ Ok(header_text(headers, "x-apple-alternate-id")?.map(AltDsid)) |
|
| 28 |
+ } |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 22 | 31 |
impl AsRef<str> for AltDsid {
|
| 23 | 32 |
fn as_ref(&self) -> &str {
|
| 24 | 33 |
&self.0 |
| ... | ... |
@@ -29,6 +38,24 @@ impl AsRef<str> for AltDsid {
|
| 29 | 38 |
#[serde(transparent)] |
| 30 | 39 |
pub struct IdmsToken(pub(super) String); |
| 31 | 40 |
|
| 41 |
+impl IdmsToken {
|
|
| 42 |
+ /// Read the identity token without requiring app tokens or an account identifier. |
|
| 43 |
+ pub fn from_response_headers( |
|
| 44 |
+ headers: &reqwest::header::HeaderMap, |
|
| 45 |
+ ) -> Result<Option<IdmsToken>, ResponseTokenError> {
|
|
| 46 |
+ let name = "x-apple-identity-token"; |
|
| 47 |
+ let mut values = headers.get_all(name).iter(); |
|
| 48 |
+ let token = values |
|
| 49 |
+ .next() |
|
| 50 |
+ .map(|value| header_token(value, name)) |
|
| 51 |
+ .transpose()?; |
|
| 52 |
+ if values.next().is_some() {
|
|
| 53 |
+ return Err(ResponseTokenError::InvalidHeader(name)); |
|
| 54 |
+ } |
|
| 55 |
+ Ok(token.map(|(_, token)| IdmsToken(token.token))) |
|
| 56 |
+ } |
|
| 57 |
+} |
|
| 58 |
+ |
|
| 32 | 59 |
#[derive(Debug, Clone, Serialize, Deserialize)] |
| 33 | 60 |
#[serde(transparent)] |
| 34 | 61 |
pub struct SessionKey(Vec<u8>); |
| ... | ... |
@@ -37,7 +64,8 @@ pub struct SessionKey(Vec<u8>); |
| 37 | 64 |
#[serde(transparent)] |
| 38 | 65 |
pub struct AuthCookie(Vec<u8>); |
| 39 | 66 |
|
| 40 |
-#[derive(Debug, Clone)] |
|
| 67 |
+#[derive(Debug, Clone, Deserialize)] |
|
| 68 |
+#[serde(transparent)] |
|
| 41 | 69 |
pub struct ServerProvidedData(pub(super) Dictionary); |
| 42 | 70 |
|
| 43 | 71 |
impl ServerProvidedData {
|
| ... | ... |
@@ -256,3 +284,64 @@ impl<'lt, 'adi> AccountHTTPSession<'lt, 'adi> {
|
| 256 | 284 |
.ok_or_else(|| AppTokenRequestError::InvalidResponse(tokens.0)) |
| 257 | 285 |
} |
| 258 | 286 |
} |
| 287 |
+ |
|
| 288 |
+pub(super) fn header_text( |
|
| 289 |
+ headers: &reqwest::header::HeaderMap, |
|
| 290 |
+ name: &'static str, |
|
| 291 |
+) -> Result<Option<String>, ResponseTokenError> {
|
|
| 292 |
+ let mut values = headers.get_all(name).iter(); |
|
| 293 |
+ let value = values |
|
| 294 |
+ .next() |
|
| 295 |
+ .map(|value| {
|
|
| 296 |
+ value |
|
| 297 |
+ .to_str() |
|
| 298 |
+ .map(str::to_owned) |
|
| 299 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name)) |
|
| 300 |
+ }) |
|
| 301 |
+ .transpose()?; |
|
| 302 |
+ if values.next().is_some() || value.as_ref().is_some_and(String::is_empty) {
|
|
| 303 |
+ return Err(ResponseTokenError::InvalidHeader(name)); |
|
| 304 |
+ } |
|
| 305 |
+ Ok(value) |
|
| 306 |
+} |
|
| 307 |
+ |
|
| 308 |
+pub(super) fn header_token( |
|
| 309 |
+ value: &reqwest::header::HeaderValue, |
|
| 310 |
+ name: &'static str, |
|
| 311 |
+) -> Result<(String, Token), ResponseTokenError> {
|
|
| 312 |
+ use base64::{Engine, prelude::BASE64_STANDARD};
|
|
| 313 |
+ let bytes = BASE64_STANDARD |
|
| 314 |
+ .decode(value.as_bytes()) |
|
| 315 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 316 |
+ let value = std::str::from_utf8(&bytes).map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 317 |
+ let fields: Vec<_> = value.split(':').collect();
|
|
| 318 |
+ if fields.len() != 4 || fields[0].is_empty() || fields[1].is_empty() {
|
|
| 319 |
+ return Err(ResponseTokenError::InvalidHeader(name)); |
|
| 320 |
+ } |
|
| 321 |
+ let duration: u64 = fields[2] |
|
| 322 |
+ .parse() |
|
| 323 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 324 |
+ let issued: u64 = fields[3] |
|
| 325 |
+ .parse() |
|
| 326 |
+ .map_err(|_| ResponseTokenError::InvalidHeader(name))?; |
|
| 327 |
+ let expiry_epoch_millis = duration |
|
| 328 |
+ .checked_mul(1000) |
|
| 329 |
+ .and_then(|duration| issued.checked_add(duration)) |
|
| 330 |
+ .ok_or(ResponseTokenError::InvalidHeader(name))?; |
|
| 331 |
+ Ok(( |
|
| 332 |
+ fields[0].to_owned(), |
|
| 333 |
+ Token {
|
|
| 334 |
+ token: fields[1].to_owned(), |
|
| 335 |
+ duration, |
|
| 336 |
+ expiry_epoch_millis, |
|
| 337 |
+ }, |
|
| 338 |
+ )) |
|
| 339 |
+} |
|
| 340 |
+ |
|
| 341 |
+#[derive(Debug, Error)] |
|
| 342 |
+pub enum ResponseTokenError {
|
|
| 343 |
+ #[error("Invalid authentication response header: {0}")]
|
|
| 344 |
+ InvalidHeader(&'static str), |
|
| 345 |
+ #[error("Duplicate service token in authentication response")]
|
|
| 346 |
+ DuplicateService, |
|
| 347 |
+} |
| ... | ... |
@@ -75,6 +75,14 @@ pub async fn provision( |
| 75 | 75 |
"midStartProvisioning".into(), |
| 76 | 76 |
))?; |
| 77 | 77 |
|
| 78 |
+ let mid_finish_provisioning_url = http_session |
|
| 79 |
+ .url_bag() |
|
| 80 |
+ .get("midFinishProvisioning")
|
|
| 81 |
+ .and_then(Value::as_string) |
|
| 82 |
+ .ok_or(ProvisioningError::URLNotFound( |
|
| 83 |
+ "midFinishProvisioning".into(), |
|
| 84 |
+ ))?; |
|
| 85 |
+ |
|
| 78 | 86 |
let start_provisioning_request_builder = |
| 79 | 87 |
http_session.simple_request_builder(Method::GET, mid_start_provisioning_url); |
| 80 | 88 |
|
| ... | ... |
@@ -108,14 +116,6 @@ pub async fn provision( |
| 108 | 116 |
.start_provisioning(GRANDSLAM_DSID, &spim) |
| 109 | 117 |
.map_err(ProvisioningError::ADIError)?; |
| 110 | 118 |
|
| 111 |
- let mid_finish_provisioning_url = http_session |
|
| 112 |
- .url_bag() |
|
| 113 |
- .get("midFinishProvisioning")
|
|
| 114 |
- .and_then(Value::as_string) |
|
| 115 |
- .ok_or(ProvisioningError::URLNotFound( |
|
| 116 |
- "midFinishProvisioning".into(), |
|
| 117 |
- ))?; |
|
| 118 |
- |
|
| 119 | 119 |
// Soon try blocks will come to Rust :,) |
| 120 | 120 |
let ptm_tk_result = async {
|
| 121 | 121 |
let finish_provisioning_request_builder = |
| ... | ... |
@@ -1,4 +1,4 @@ |
| 1 |
-use super::account::AccountHTTPSession; |
|
| 1 |
+use super::account::{AccountHTTPSession, ResponseTokenError, header_token};
|
|
| 2 | 2 |
use adi::proxy::ADIResult; |
| 3 | 3 |
use plist::Dictionary; |
| 4 | 4 |
use reqwest::{Method, RequestBuilder};
|
| ... | ... |
@@ -22,6 +22,34 @@ pub trait AppToken: From<String> + AsRef<str> {
|
| 22 | 22 |
pub struct TokenBag(pub(super) Dictionary); |
| 23 | 23 |
|
| 24 | 24 |
impl TokenBag {
|
| 25 |
+ /// Extract app and heartbeat tokens. Missing headers produce an empty bag. |
|
| 26 |
+ pub fn from_response_headers( |
|
| 27 |
+ headers: &reqwest::header::HeaderMap, |
|
| 28 |
+ ) -> Result<Self, ResponseTokenError> {
|
|
| 29 |
+ let mut tokens = Dictionary::new(); |
|
| 30 |
+ for name in ["x-apple-gs-token", "x-apple-hb-token"] {
|
|
| 31 |
+ for value in headers.get_all(name) {
|
|
| 32 |
+ let (service, token) = header_token(value, name)?; |
|
| 33 |
+ let value = plist_macros::dict! {
|
|
| 34 |
+ "token": token.token, |
|
| 35 |
+ "duration": token.duration, |
|
| 36 |
+ "expiry": token.expiry_epoch_millis, |
|
| 37 |
+ }; |
|
| 38 |
+ if tokens.insert(service, value.into()).is_some() {
|
|
| 39 |
+ return Err(ResponseTokenError::DuplicateService); |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ Ok(Self(tokens)) |
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ pub fn entries(&self) -> Result<std::collections::BTreeMap<String, Token>, plist::Error> {
|
|
| 47 |
+ self.0 |
|
| 48 |
+ .iter() |
|
| 49 |
+ .map(|(service, value)| Ok((service.clone(), plist::from_value(value)?))) |
|
| 50 |
+ .collect() |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 25 | 53 |
pub fn get<T: AppToken>(&self) -> Option<Token<T>> {
|
| 26 | 54 |
let token: Token = plist::from_value(self.0.get(T::APP_TOKEN_IDENTIFIER)?).ok()?; |
| 27 | 55 |
Some(Token {
|