Add continuation token support
Dadoum

Dadoum commited on 2026-09-27 23:43:22
Showing 6 changed files, with 105 additions and 12 deletions.

... ...
@@ -301,7 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
301 301
 checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
302 302
 
303 303
 [[package]]
304
-name = "apple-codesign"
304
+name = "apple-codesign-quick"
305 305
 version = "0.1.0"
306 306
 dependencies = [
307 307
  "byteorder",
... ...
@@ -3140,6 +3140,7 @@ dependencies = [
3140 3140
  "srp",
3141 3141
  "sys-locale",
3142 3142
  "thiserror 2.0.18",
3143
+ "tokio",
3143 3144
 ]
3144 3145
 
3145 3146
 [[package]]
... ...
@@ -7088,7 +7089,7 @@ dependencies = [
7088 7089
  "adi",
7089 7090
  "adid-proxy",
7090 7091
  "android-coreadi",
7091
- "apple-codesign",
7092
+ "apple-codesign-quick",
7092 7093
  "async_zip",
7093 7094
  "chrono",
7094 7095
  "core-foundation 0.10.0",
... ...
@@ -28,3 +28,6 @@ rand = "0.9.2"
28 28
 hex = "0.4.3"
29 29
 bytes = "1.10.1"
30 30
 log = "0.4.29"
31
+
32
+[dev-dependencies]
33
+tokio.workspace = true
... ...
@@ -7,3 +7,41 @@ StoreServicesCore and AppleMediaServices.
7 7
 
8 8
 `itunes` is modeled after some personal reversing of iTunes, and 
9 9
 the code of [ipatool](https://github.com/majd/ipatool).
10
+
11
+## Continuation tokens
12
+
13
+Password login requests a continuation token using `ckgen`. When Apple issues
14
+one, extract it from the decrypted server-provided data:
15
+
16
+```rust,ignore
17
+let outcome = grandslam::login(&session, apple_id, password).await?;
18
+if let grandslam::AuthOutcome::Success(data) = outcome {
19
+    if let Some(token) = grandslam::ContinuationToken::from_server_provided_data(&data) {
20
+        // Save the token securely with this Apple ID and device identity.
21
+        let resumed = grandslam::login_with_continuation_token(&session, apple_id, &token).await?;
22
+    }
23
+}
24
+```
25
+
26
+Continuation login uses the decoded token as the SRP secret and sends
27
+`cpd.ckauth = true` in both exchanges, omitting `bootstrap` and `ckgen`.
28
+It returns the same `AuthOutcome` as password login, including secondary actions.
29
+A successful response preserves the supplied token in `ck` if Apple does not
30
+return a replacement. Extract and save the token again after success to handle
31
+rotation. The library does not persist credentials or automatically fall back to
32
+a password when a token is rejected.
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.
41
+
42
+These details were traced in AuthKitWin 394.2 in Ghidra:
43
+`_CopyAuthParametersForAccount` (`0x18003ca60`) selects the SRP secret and flags;
44
+`_CreateServerResponseFromAuthContext` (`0x18003d570`) reads `ck` and retains the
45
+input credential when no replacement is issued;
46
+`AKCreateUpdatedServerResponse` (header lookup at `0x1800391a4`) decodes
47
+`X-Apple-I-CK` before passing it to `SaveCKToken` (`0x18003c590`).
... ...
@@ -195,9 +195,7 @@ pub fn build_client_provided_data(
195 195
 
196 196
         "X-Mme-Device-Id": device.device_uuid.clone(),
197 197
 
198
-        "bootstrap": true,
199 198
         "capp": application_information.bundle_name,
200
-        "ckgen": true,
201 199
         "icscrec": true,
202 200
         "loc": locale,
203 201
         "pbe": false,
... ...
@@ -234,10 +232,46 @@ impl TryFrom<u64> for StatusCode {
234 232
     }
235 233
 }
236 234
 
235
+#[derive(Clone, PartialEq, Eq)]
236
+pub struct ContinuationToken(String);
237
+
237 238
 pub async fn login(
238 239
     http_session: &AnisetteHTTPSession<'_, '_>,
239 240
     apple_id: &str,
240 241
     password: &str,
242
+) -> AuthResult {
243
+    login_with_credential(http_session, apple_id, LoginCredential::Password(password)).await
244
+}
245
+
246
+/// Authenticate using a previously issued continuation token instead of a password.
247
+///
248
+/// Use the Apple ID and device identity associated with the token. The server can
249
+/// still require a secondary action or reject a revoked token; callers decide
250
+/// whether to fall back to password authentication. Extract the token from the
251
+/// returned server-provided data again after success to pick up a replacement.
252
+pub async fn login_with_continuation_token(
253
+    http_session: &AnisetteHTTPSession<'_, '_>,
254
+    apple_id: &str,
255
+    continuation_token: &ContinuationToken,
256
+) -> AuthResult {
257
+    login_with_credential(
258
+        http_session,
259
+        apple_id,
260
+        LoginCredential::ContinuationToken(continuation_token),
261
+    )
262
+    .await
263
+}
264
+
265
+#[derive(Clone, Copy)]
266
+enum LoginCredential<'a> {
267
+    Password(&'a str),
268
+    ContinuationToken(&'a ContinuationToken),
269
+}
270
+
271
+async fn login_with_credential(
272
+    http_session: &AnisetteHTTPSession<'_, '_>,
273
+    apple_id: &str,
274
+    credential: LoginCredential<'_>,
241 275
 ) -> AuthResult {
242 276
     let gs_service_url = http_session
243 277
         .url_bag()
... ...
@@ -245,7 +279,21 @@ pub async fn login(
245 279
         .and_then(Value::as_string)
246 280
         .ok_or(AuthError::InvalidURLBag)?;
247 281
 
248
-    let cpd = build_client_provided_data(http_session)?;
282
+    let cpd = {
283
+        let mut cpd = build_client_provided_data(http_session)?;
284
+
285
+        match credential {
286
+            LoginCredential::ContinuationToken(_) => {
287
+                cpd.insert("ckauth".into(), true.into());
288
+            }
289
+            LoginCredential::Password(_) => {
290
+                cpd.insert("bootstrap".into(), true.into());
291
+                cpd.insert("ckgen".into(), true.into());
292
+            }
293
+        };
294
+
295
+        cpd
296
+    };
249 297
 
250 298
     // TODO: implement s4k, if some day someone needs that.
251 299
     let srp_client = srp::Client::<G2048, Sha256>::new_with_options(false);
... ...
@@ -259,7 +307,7 @@ pub async fn login(
259 307
         },
260 308
         "Request": dict!{
261 309
             "A2k": Value::Data(a_pub),
262
-            "cpd": cpd,
310
+            "cpd": cpd.clone(),
263 311
             "o": "init",
264 312
             "ps": array![
265 313
                 "s2k",
... ...
@@ -322,11 +370,16 @@ pub async fn login(
322 370
         .and_then(|b| b.as_data())
323 371
         .ok_or_else(|| AuthError::Structure(response_plist.clone()))?;
324 372
 
373
+    let secret = match credential {
374
+        LoginCredential::Password(password) => { password },
375
+        LoginCredential::ContinuationToken(ck) => { ck.0.as_str() }
376
+    }.as_bytes();
377
+
325 378
     let hashed_password: Vec<u8> = match selected_protocol {
326 379
         // SRP with a 2048/4096-bit long A.
327
-        "s2k" | "s4k" => Sha256::digest(password.as_bytes()).to_vec(),
380
+        "s2k" | "s4k" => Sha256::digest(secret).to_vec(),
328 381
         // SRP with a 2048-bit long A + fo?
329
-        "s2k_fo" => hex::encode(Sha256::digest(password.as_bytes())).into_bytes(),
382
+        "s2k_fo" => hex::encode(Sha256::digest(secret)).into_bytes(),
330 383
         _ => {
331 384
             return Err(AuthError::UnknownProtocol(selected_protocol.into()));
332 385
         }
... ...
@@ -342,8 +395,6 @@ pub async fn login(
342 395
     let verifier =
343 396
         srp_client.process_reply(&a, apple_id.as_bytes(), &processed_password, salt, b)?;
344 397
 
345
-    let cpd = build_client_provided_data(http_session)?;
346
-
347 398
     let request_plist = dict! {
348 399
         "Header": dict!{
349 400
             "Version": "1.0.1"
... ...
@@ -6,7 +6,7 @@ edition = "2021"
6 6
 [dependencies]
7 7
 adi.workspace = true
8 8
 android-coreadi.workspace = true
9
-apple-codesign = { path = "../apple-codesign-quick" }
9
+apple-codesign-quick = { path = "../apple-codesign-quick" }
10 10
 async_zip = { version = "0.0.18", features = ["deflate", "tokio", "tokio-fs"] }
11 11
 chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
12 12
 crc32fast = "1.5"
... ...
@@ -8,7 +8,7 @@ use crate::domain::{
8 8
     AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, NestedBundle,
9 9
     NestedBundleKind, Patch, SupportedDeviceFamily,
10 10
 };
11
-use apple_codesign::{
11
+use apple_codesign_quick::{
12 12
     sign_bundle, BundleSigningSettings, ProvisioningProfile, RustCryptoCmsSigner,
13 13
 };
14 14
 use async_zip::base::read::mem::ZipFileReader as MemoryZipFileReader;
15 15