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", |
| ... | ... |
@@ -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 |