Add helpers to grandslam
Dadoum

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 {
... ...
@@ -154,6 +154,7 @@ impl<'lt> BasicHTTPSession<'lt> {
154 154
         };
155 155
 
156 156
         let client = client_builder
157
+            .redirect(reqwest::redirect::Policy::none())
157 158
             .default_headers(headers)
158 159
             .connection_verbose(false)
159 160
             .build()
160 161