From 8be330a6be70bfbd1c722d3ea7df2ca6af2f0f2a Mon Sep 17 00:00:00 2001 From: nab138 Date: Sun, 25 Jan 2026 16:29:07 -0500 Subject: [PATCH] Logging in (with trusted device 2fa only) --- Cargo.lock | 121 ++++++- examples/minimal/src/main.rs | 8 +- isideload/Cargo.toml | 5 + isideload/src/anisette/mod.rs | 83 ++++- isideload/src/anisette/remote_v3/mod.rs | 225 ++++++------- isideload/src/anisette/remote_v3/state.rs | 2 +- isideload/src/auth/apple_account.rs | 377 ++++++++++++++++++++-- isideload/src/auth/grandslam.rs | 88 ++++- isideload/src/lib.rs | 31 ++ isideload/src/util/plist.rs | 53 ++- 10 files changed, 821 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 188bca2..c7a0bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -92,7 +103,16 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", ] [[package]] @@ -107,6 +127,15 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.53" @@ -150,6 +179,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.57" @@ -236,7 +275,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -263,6 +302,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -397,6 +437,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -455,6 +505,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -711,6 +770,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array 0.14.7", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -731,12 +800,17 @@ dependencies = [ name = "isideload" version = "0.2.0" dependencies = [ + "aes", "async-trait", "base64", + "cbc", "chrono", "futures-util", "hex", + "hmac", "idevice", + "nab138_srp", + "pbkdf2", "plist", "plist-macro", "rand", @@ -872,6 +946,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nab138_srp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587a7a2ae38ab9a818f42c12b02a7ad5d738006f78f3b53a9f28da91fe13411d" +dependencies = [ + "base64", + "digest", + "generic-array 1.3.5", + "lazy_static", + "num-bigint", + "subtle", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -881,12 +969,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -908,6 +1015,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index b29fd5a..f170fb8 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -8,8 +8,9 @@ use tracing_subscriber::FmtSubscriber; #[tokio::main] async fn main() { + isideload::init().expect("Failed to initialize error reporting"); let subscriber = FmtSubscriber::builder() - .with_max_level(Level::DEBUG) + .with_max_level(Level::INFO) .finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); @@ -31,13 +32,12 @@ async fn main() { }; let account = AppleAccountBuilder::new(apple_id) - .danger_debug(true) - .anisette(RemoteV3AnisetteProvider::default().set_serial_number("2".to_string())) + .anisette_provider(RemoteV3AnisetteProvider::default().set_serial_number("2".to_string())) .login(apple_password, get_2fa_code) .await; match account { Ok(_account) => println!("Successfully logged in to Apple ID"), - Err(e) => eprintln!("Failed to log in to Apple ID: {}", e), + Err(e) => eprintln!("Failed to log in to Apple ID: {:?}", e), } } diff --git a/isideload/Cargo.toml b/isideload/Cargo.toml index 4e924fe..cd52436 100644 --- a/isideload/Cargo.toml +++ b/isideload/Cargo.toml @@ -33,3 +33,8 @@ futures-util = "0.3.31" serde_json = "1.0.149" base64 = "0.22.1" hex = "0.4.3" +srp = { package = "nab138_srp", version = "0.6.0" } +pbkdf2 = "0.12.2" +hmac = "0.12.1" +cbc = { version = "0.1.2", features = ["std"] } +aes = "0.8.4" diff --git a/isideload/src/anisette/mod.rs b/isideload/src/anisette/mod.rs index 196fe25..dd04c94 100644 --- a/isideload/src/anisette/mod.rs +++ b/isideload/src/anisette/mod.rs @@ -1,6 +1,10 @@ pub mod remote_v3; use crate::auth::grandslam::GrandSlam; +use chrono::{DateTime, SubsecRound, Utc}; +use plist::Dictionary; +use plist_macro::plist; +use reqwest::header::HeaderMap; use rootcause::prelude::*; use serde::Deserialize; use std::collections::HashMap; @@ -11,12 +15,83 @@ pub struct AnisetteClientInfo { pub user_agent: String, } +#[derive(Debug, Clone)] +pub struct AnisetteData { + machine_id: String, + one_time_password: String, + routing_info: String, + device_description: String, + device_unique_identifier: String, + local_user_id: String, +} + +impl AnisetteData { + pub fn get_headers(&self, serial: String) -> HashMap { + let dt: DateTime = Utc::now().round_subsecs(0); + + HashMap::from_iter( + [ + ( + "X-Apple-I-Client-Time".to_string(), + dt.format("%+").to_string().replace("+00:00", "Z"), + ), + ("X-Apple-I-SRL-NO".to_string(), serial), + ("X-Apple-I-TimeZone".to_string(), "UTC".to_string()), + ("X-Apple-Locale".to_string(), "en_US".to_string()), + ("X-Apple-I-MD-RINFO".to_string(), self.routing_info.clone()), + ("X-Apple-I-MD-LU".to_string(), self.local_user_id.clone()), + ( + "X-Mme-Device-Id".to_string(), + self.device_unique_identifier.clone(), + ), + ("X-Apple-I-MD".to_string(), self.one_time_password.clone()), + ("X-Apple-I-MD-M".to_string(), self.machine_id.clone()), + ( + "X-Mme-Client-Info".to_string(), + self.device_description.clone(), + ), + ] + .into_iter(), + ) + } + + pub fn get_header_map(&self, serial: String) -> HeaderMap { + let headers_map = self.get_headers(serial); + let mut header_map = HeaderMap::new(); + + for (key, value) in headers_map { + header_map.insert( + reqwest::header::HeaderName::from_bytes(key.as_bytes()).unwrap(), + reqwest::header::HeaderValue::from_str(&value).unwrap(), + ); + } + + header_map + } + + pub fn get_client_provided_data(&self, serial: String) -> Dictionary { + let headers = self.get_headers(serial); + + let mut cpd = plist!(dict { + "bootstrap": "true", + "icscrec": "true", + "loc": "en_US", + "pbe": "false", + "prkgen": "true", + "svct": "iCloud" + }); + + for (key, value) in headers { + cpd.insert(key.to_string(), plist::Value::String(value)); + } + + cpd + } +} + #[async_trait::async_trait] pub trait AnisetteProvider { - async fn get_anisette_headers( - &mut self, - gs: &mut GrandSlam, - ) -> Result, Report>; + async fn get_anisette_data(&mut self, gs: &mut GrandSlam) -> Result; async fn get_client_info(&mut self) -> Result; } diff --git a/isideload/src/anisette/remote_v3/mod.rs b/isideload/src/anisette/remote_v3/mod.rs index bc63071..7f06273 100644 --- a/isideload/src/anisette/remote_v3/mod.rs +++ b/isideload/src/anisette/remote_v3/mod.rs @@ -1,67 +1,25 @@ mod state; -use std::collections::HashMap; use std::fs; use std::path::PathBuf; use base64::prelude::*; -use chrono::{DateTime, SubsecRound, Utc}; +use chrono::{SubsecRound, Utc}; use plist_macro::plist; -use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; use rootcause::prelude::*; use serde::Deserialize; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, info}; use crate::anisette::remote_v3::state::AnisetteState; -use crate::anisette::{AnisetteClientInfo, AnisetteProvider}; +use crate::anisette::{AnisetteClientInfo, AnisetteData, AnisetteProvider}; use crate::auth::grandslam::GrandSlam; -use crate::util::plist::plist_to_xml_string; +use crate::util::plist::PlistDataExtract; use futures_util::{SinkExt, StreamExt}; pub const DEFAULT_ANISETTE_V3_URL: &str = "https://ani.sidestore.io"; -#[derive(Debug)] -pub struct AnisetteData { - machine_id: String, - one_time_password: String, - routing_info: String, - device_description: String, - device_unique_identifier: String, - local_user_id: String, -} - -impl AnisetteData { - pub fn get_headers(&self, serial: String) -> HashMap { - let dt: DateTime = Utc::now().round_subsecs(0); - - HashMap::from_iter( - [ - ( - "X-Apple-I-Client-Time".to_string(), - dt.format("%+").to_string().replace("+00:00", "Z"), - ), - ("X-Apple-I-SRL-NO".to_string(), serial), - ("X-Apple-I-TimeZone".to_string(), "UTC".to_string()), - ("X-Apple-Locale".to_string(), "en_US".to_string()), - ("X-Apple-I-MD-RINFO".to_string(), self.routing_info.clone()), - ("X-Apple-I-MD-LU".to_string(), self.local_user_id.clone()), - ( - "X-Mme-Device-Id".to_string(), - self.device_unique_identifier.clone(), - ), - ("X-Apple-I-MD".to_string(), self.one_time_password.clone()), - ("X-Apple-I-MD-M".to_string(), self.machine_id.clone()), - ( - "X-Mme-Client-Info".to_string(), - self.device_description.clone(), - ), - ] - .into_iter(), - ) - } -} - pub struct RemoteV3AnisetteProvider { pub state: Option, url: String, @@ -117,23 +75,57 @@ impl Default for RemoteV3AnisetteProvider { #[async_trait::async_trait] impl AnisetteProvider for RemoteV3AnisetteProvider { - async fn get_anisette_headers( - &mut self, - gs: &mut GrandSlam, - ) -> Result, Report> { - let state = self.get_state(gs).await?; + async fn get_anisette_data(&mut self, gs: &mut GrandSlam) -> Result { + let state = self.get_state(gs).await?.clone(); + let adi_pb = state + .adi_pb + .as_ref() + .ok_or(report!("Anisette state is not provisioned"))?; + let client_info = self.get_client_info().await?.client_info.clone(); - unimplemented!() + let headers = self + .client + .post(format!("{}/v3/get_headers", self.url)) + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::json!({ + "identifier": BASE64_STANDARD.encode(&state.keychain_identifier), + "adi_pb": BASE64_STANDARD.encode(adi_pb) + }) + .to_string(), + ) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + match headers { + AnisetteHeaders::Headers { + machine_id, + one_time_password, + routing_info, + } => { + let data = AnisetteData { + machine_id, + one_time_password, + routing_info, + device_description: client_info, + device_unique_identifier: state.get_device_id(), + local_user_id: hex::encode(&state.get_md_lu()), + }; + + Ok(data) + } + AnisetteHeaders::GetHeadersError { message } => { + Err(report!("Failed to get anisette headers") + .attach(message) + .into()) + } + } } async fn get_client_info(&mut self) -> Result { - self.ensure_client_info().await?; - Ok(self.client_info.as_ref().unwrap().clone()) - } -} - -impl RemoteV3AnisetteProvider { - async fn ensure_client_info(&mut self) -> Result<(), Report> { if self.client_info.is_none() { let resp = self .client @@ -147,20 +139,20 @@ impl RemoteV3AnisetteProvider { self.client_info = Some(resp); } - debug!("Got client client_info: {:?}", self.client_info); - - Ok(()) + Ok(self.client_info.as_ref().unwrap().clone()) } +} +impl RemoteV3AnisetteProvider { async fn get_state(&mut self, gs: &mut GrandSlam) -> Result<&mut AnisetteState, Report> { let state_path = self.config_path.join("state.plist"); fs::create_dir_all(&self.config_path)?; if self.state.is_none() { if let Ok(state) = plist::from_file(&state_path) { - debug!("Loaded existing anisette state from {:?}", state_path); + info!("Loaded existing anisette state from {:?}", state_path); self.state = Some(state); } else { - debug!("No existing anisette state found"); + info!("No existing anisette state found"); self.state = Some(AnisetteState::new()); } } @@ -177,10 +169,7 @@ impl RemoteV3AnisetteProvider { Ok(state) } - async fn provisioning_headers( - state: &AnisetteState, - gs: &mut GrandSlam, - ) -> Result { + async fn provisioning_headers(state: &AnisetteState) -> Result { let mut headers = HeaderMap::new(); headers.insert( "X-Apple-I-MD-LU", @@ -211,25 +200,14 @@ impl RemoteV3AnisetteProvider { url: &str, ) -> Result<(), Report> { info!("Starting provisioning"); - let urls = gs.get_url_bag().await?; - let start_provisioning = urls - .get("midStartProvisioning") - .and_then(|v| v.as_string()) - .ok_or(report!("Missing URL bag entry for midStartProvisioning"))? - .to_string(); - let end_provisioning = urls - .get("midFinishProvisioning") - .and_then(|v| v.as_string()) - .ok_or(report!("Missing URL bag entry for midFinishProvisioning"))? - .to_string(); + let start_provisioning = gs.get_url("midStartProvisioning").await?; + let end_provisioning = gs.get_url("midFinishProvisioning").await?; let websocket_url = format!("{}/v3/provisioning_session", url) .replace("https://", "wss://") .replace("http://", "ws://"); - let (mut ws_stream, _) = tokio_tungstenite::connect_async(&websocket_url) - .await - .context("Failed to connect anisette provisioning socket")?; + let (mut ws_stream, _) = tokio_tungstenite::connect_async(&websocket_url).await?; loop { let Some(msg) = ws_stream.next().await else { @@ -266,28 +244,18 @@ impl RemoteV3AnisetteProvider { "Request": {} }); - let resp = gs - .post(&start_provisioning)? - .headers(Self::provisioning_headers(state, gs).await?) - .body(plist_to_xml_string(&body)) - .send() + let response = gs + .plist_request( + &start_provisioning, + &body, + Some(Self::provisioning_headers(state).await?), + ) .await - .context("Failed to send start provisioning request")? - .error_for_status() - .context("Start provisioning request returned error")? - .text() - .await - .context("Failed to read start provisioning response text")?; + .context("Failed to send start provisioning request")?; - let resp_plist: plist::Dictionary = plist::from_bytes(resp.as_bytes()) - .context("Failed to parse start provisioning response plist")?; - - let spim = resp_plist - .get("Response") - .and_then(|v| v.as_dictionary()) - .and_then(|d| d.get("spim")) - .and_then(|v| v.as_string()) - .ok_or(report!("Start provisioning response missing spim"))?; + let spim = response + .get_str("spim") + .context("Start provisioning response missing spim")?; ws_stream .send(Message::Text( @@ -308,39 +276,24 @@ impl RemoteV3AnisetteProvider { } }); - let resp = gs - .post(&end_provisioning)? - .headers(Self::provisioning_headers(state, gs).await?) - .body(plist_to_xml_string(&body)) - .send() + let response = gs + .plist_request( + &end_provisioning, + &body, + Some(Self::provisioning_headers(state).await?), + ) .await - .context("Failed to send end provisioning request")? - .error_for_status() - .context("End provisioning request returned error")? - .text() - .await - .context("Failed to read end provisioning response text")?; - - let resp_plist: plist::Dictionary = plist::from_bytes(resp.as_bytes()) - .context("Failed to parse end provisioning response plist")?; - let response = resp_plist - .get("Response") - .and_then(|v| v.as_dictionary()) - .ok_or(report!( - "End provisioning response missing Response dictionary" - ))?; + .context("Failed to send end provisioning request")?; ws_stream .send(Message::Text( serde_json::json!({ "ptm": response - .get("ptm") - .and_then(|v| v.as_string()) - .ok_or(report!("End provisioning response missing ptm"))?, + .get_str("ptm") + .context("End provisioning response missing ptm")?, "tk": response - .get("tk") - .and_then(|v| v.as_string()) - .ok_or(report!("End provisioning response missing tk"))?, + .get_str("tk") + .context("End provisioning response missing tk")?, }) .to_string() .into(), @@ -391,3 +344,19 @@ enum ProvisioningMessage { StartProvisioningError { message: String }, EndProvisioningError { message: String }, } + +#[derive(Deserialize)] +#[serde(tag = "result")] +enum AnisetteHeaders { + GetHeadersError { + message: String, + }, + Headers { + #[serde(rename = "X-Apple-I-MD-M")] + machine_id: String, + #[serde(rename = "X-Apple-I-MD")] + one_time_password: String, + #[serde(rename = "X-Apple-I-MD-RINFO")] + routing_info: String, + }, +} diff --git a/isideload/src/anisette/remote_v3/state.rs b/isideload/src/anisette/remote_v3/state.rs index d87499b..a4aad20 100644 --- a/isideload/src/anisette/remote_v3/state.rs +++ b/isideload/src/anisette/remote_v3/state.rs @@ -37,7 +37,7 @@ where Ok(s.try_into().unwrap()) } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct AnisetteState { #[serde( serialize_with = "bin_serialize", diff --git a/isideload/src/auth/apple_account.rs b/isideload/src/auth/apple_account.rs index 281e820..bd2673f 100644 --- a/isideload/src/auth/apple_account.rs +++ b/isideload/src/auth/apple_account.rs @@ -1,21 +1,48 @@ use crate::{ - anisette::{AnisetteProvider, remote_v3::RemoteV3AnisetteProvider}, - auth::grandslam::GrandSlam, + anisette::{AnisetteData, AnisetteProvider, remote_v3::RemoteV3AnisetteProvider}, + auth::grandslam::{GrandSlam, GrandSlamErrorChecker}, + util::plist::PlistDataExtract, }; +use aes::cipher::block_padding::Pkcs7; +use base64::{Engine, prelude::BASE64_STANDARD}; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use hmac::{Hmac, Mac}; +use plist::Dictionary; +use plist_macro::plist; +use reqwest::header::HeaderMap; use rootcause::prelude::*; -use tracing::{info, warn}; +use sha2::{Digest, Sha256}; +use srp::{ + client::{SrpClient, SrpClientVerifier}, + groups::G_2048, +}; +use tracing::{debug, info, warn}; + +const SERIAL_NUMBER: &str = "2"; pub struct AppleAccount { pub email: String, pub spd: Option, - pub anisette: Box, + pub anisette_provider: Box, + pub anisette_data: AnisetteData, pub grandslam_client: GrandSlam, + login_state: LoginState, + debug: bool, } pub struct AppleAccountBuilder { email: String, debug: Option, - anisette: Option>, + anisette_provider: Option>, +} + +#[derive(Debug)] +pub enum LoginState { + LoggedIn, + NeedsDevice2FA, + NeedsSMS2FA, + NeedsExtraStep(String), + NeedsLogin, } impl AppleAccountBuilder { @@ -27,7 +54,7 @@ impl AppleAccountBuilder { Self { email: email.to_string(), debug: None, - anisette: None, + anisette_provider: None, } } @@ -40,11 +67,24 @@ impl AppleAccountBuilder { self } - pub fn anisette(mut self, anisette: impl AnisetteProvider + 'static) -> Self { - self.anisette = Some(Box::new(anisette)); + pub fn anisette_provider(mut self, anisette_provider: impl AnisetteProvider + 'static) -> Self { + self.anisette_provider = Some(Box::new(anisette_provider)); self } + /// Build the AppleAccount + /// + /// # Errors + /// Returns an error if the reqwest client cannot be built + pub async fn build(self) -> Result { + let debug = self.debug.unwrap_or(false); + let anisette_provider = self + .anisette_provider + .unwrap_or_else(|| Box::new(RemoteV3AnisetteProvider::default())); + + AppleAccount::new(&self.email, anisette_provider, debug).await + } + /// Build the AppleAccount and log in /// /// # Arguments @@ -60,12 +100,9 @@ impl AppleAccountBuilder { where F: Fn() -> Option, { - let debug = self.debug.unwrap_or(false); - let anisette = self - .anisette - .unwrap_or_else(|| Box::new(RemoteV3AnisetteProvider::default())); - - AppleAccount::login(&self.email, password, two_factor_callback, anisette, debug).await + let mut account = self.build().await?; + account.login(password, two_factor_callback).await?; + Ok(account) } } @@ -78,41 +115,321 @@ impl AppleAccount { AppleAccountBuilder::new(email) } - /// Log in to an Apple account with the given email + /// Build the apple account with the given email /// /// Reccomended to use the AppleAccountBuilder instead - pub async fn login( + pub async fn new( email: &str, - password: &str, - two_factor_callback: impl Fn() -> Option, - mut anisette: Box, + mut anisette_provider: Box, debug: bool, ) -> Result { - info!("Logging in to apple ID: {}", email); + info!("Initializing apple account"); if debug { warn!("Debug mode enabled: this is a security risk!"); } - let client_info = anisette + let client_info = anisette_provider .get_client_info() .await .context("Failed to get anisette client info")?; - let mut grandslam_client = GrandSlam::new(client_info, debug); - let url_bag = grandslam_client - .get_url_bag() - .await - .context("Failed to get URL bag for login")?; - let headers = anisette - .get_anisette_headers(&mut grandslam_client) + let mut grandslam_client = GrandSlam::new(client_info, debug); + + let anisette_data = anisette_provider + .get_anisette_data(&mut grandslam_client) .await - .context("Failed to get anisette headers for login")?; + .context("Failed to get anisette data for login")?; Ok(AppleAccount { email: email.to_string(), spd: None, - anisette, + anisette_provider, + anisette_data, grandslam_client, + debug, + login_state: LoginState::NeedsLogin, }) } + + pub async fn login( + &mut self, + password: &str, + two_factor_callback: impl Fn() -> Option, + ) -> Result<(), Report> { + info!("Logging in to apple ID: {}", self.email); + if self.debug { + warn!("Debug mode enabled: this is a security risk!"); + } + + self.login_state = self + .login_inner(password) + .await + .context("Failed to log in to Apple ID")?; + + debug!("Initial login successful"); + + let mut attempts = 0; + + loop { + attempts += 1; + if attempts > 10 { + bail!( + "Couldn't login after 10 attempts, aborting (current state: {:?})", + self.login_state + ); + } + match self.login_state { + LoginState::LoggedIn => { + info!("Successfully logged in to Apple ID"); + return Ok(()); + } + LoginState::NeedsDevice2FA => { + debug!("Trusted device 2FA required"); + let request_code_url = self + .grandslam_client + .get_url("trustedDeviceSecondaryAuth") + .await?; + + let submit_code_url = self.grandslam_client.get_url("validateCode").await?; + + self.grandslam_client + .get(&request_code_url)? + .headers(self.build_2fa_headers().await?) + .send() + .await + .context("Failed to request trusted device 2fa")? + .error_for_status() + .context("Trusted device 2FA request failed")?; + + info!("Trusted device 2FA request sent"); + + let code = two_factor_callback() + .ok_or_else(|| report!("No 2FA code provided, aborting"))?; + + let res = self + .grandslam_client + .get(&submit_code_url)? + .headers(self.build_2fa_headers().await?) + .header("security-code", code) + .send() + .await + .context("Failed to submit trusted device 2fa code")? + .error_for_status() + .context("Trusted device 2FA code submission failed")? + .text() + .await + .context("Failed to read trusted device 2FA response text")?; + + let plist: Dictionary = plist::from_bytes(res.as_bytes()) + .context("Failed to parse trusted device response plist") + .attach_with(|| res.clone())?; + plist + .check_grandslam_error() + .context("Trusted device 2FA rejected")?; + + debug!("Trusted device 2FA completed, need to login again"); + self.login_state = LoginState::NeedsLogin; + } + LoginState::NeedsSMS2FA => { + info!("SMS 2FA required"); + todo!(); + } + LoginState::NeedsExtraStep(_) => todo!(), + LoginState::NeedsLogin => { + debug!("Logging in again..."); + self.login_state = self + .login_inner(password) + .await + .context("Failed to login again")?; + } + } + } + } + + async fn build_2fa_headers(&mut self) -> Result { + let mut headers = self.anisette_data.get_header_map(SERIAL_NUMBER.to_string()); + + let spd = self + .spd + .as_ref() + .ok_or_else(|| report!("SPD data not available, cannot build 2FA headers"))?; + + let adsid = spd + .get_str("adsid") + .context("Failed to build 2FA headers")?; + let token = spd + .get_str("GsIdmsToken") + .context("Failed to build 2FA headers")?; + let identity = BASE64_STANDARD.encode(format!("{}:{}", adsid, token)); + + headers.insert( + "X-Apple-Identity-Token", + reqwest::header::HeaderValue::from_str(&identity)?, + ); + + Ok(headers) + } + + async fn login_inner(&mut self, password: &str) -> Result { + let gs_service_url = self.grandslam_client.get_url("gsService").await?; + + debug!("GrandSlam service URL: {}", gs_service_url); + + let cpd = self + .anisette_data + .get_client_provided_data(SERIAL_NUMBER.to_string()); + + let srp_client = SrpClient::::new(&G_2048); + let a: Vec = (0..32).map(|_| rand::random::()).collect(); + let a_pub = srp_client.compute_public_ephemeral(&a); + + let req1 = plist!(dict { + "Header": { + "Version": "1.0.1" + }, + "Request": { + "A2k": a_pub, // A2k = client public ephemeral + "cpd": cpd.clone(), // cpd = client provided data + "o": "init", // o = operation + "ps": [ // ps = protocols supported + "s2k", + "s2k_fo" + ], + "u": self.email.clone(), // u = username + } + }); + + debug!("Sending initial login request"); + + let response = self + .grandslam_client + .plist_request(&gs_service_url, &req1, None) + .await + .context("Failed to send initial login request")? + .check_grandslam_error() + .context("GrandSlam error during initial login request")?; + + debug!("Login step 1 completed"); + + let salt = response + .get_data("s") + .context("Failed to parse initial login response")?; + let b_pub = response + .get_data("B") + .context("Failed to parse initial login response")?; + let iters = response + .get_signed_integer("i") + .context("Failed to parse initial login response")?; + let c = response + .get_str("c") + .context("Failed to parse initial login response")?; + let selected_protocol = response + .get_str("sp") + .context("Failed to parse initial login response")?; + + debug!( + "Selected SRP protocol: {}, iterations: {}", + selected_protocol, iters + ); + + if selected_protocol != "s2k" && selected_protocol != "s2k_fo" { + bail!("Unsupported SRP protocol selected: {}", selected_protocol); + } + + let hashed_password = Sha256::digest(password.as_bytes()); + + let password_hash = if selected_protocol == "s2k_fo" { + hex::encode(&hashed_password).into_bytes() + } else { + hashed_password.to_vec() + }; + + let mut password_buf = [0u8; 32]; + pbkdf2::pbkdf2::>(&password_hash, salt, iters as u32, &mut password_buf) + .context("Failed to derive password using PBKDF2")?; + + let verifier: SrpClientVerifier = srp_client + .process_reply(&a, &self.email.as_bytes(), &password_buf, salt, b_pub) + .unwrap(); + + let req2 = plist!(dict { + "Header": { + "Version": "1.0.1" + }, + "Request": { + "M1": verifier.proof().to_vec(), // A2k = client public ephemeral + "c": c, // c = client proof from step 1 + "cpd": cpd, // cpd = client provided data + "o": "complete", // o = operation + "u": self.email.clone(), // u = username + } + }); + + debug!("Sending proof login request"); + + let response2 = self + .grandslam_client + .plist_request(&gs_service_url, &req2, None) + .await + .context("Failed to send proof login request")? + .check_grandslam_error() + .context("GrandSlam error during proof login request")?; + + debug!("Login step 2 response received, verifying server proof"); + + let m2 = response2 + .get_data("M2") + .context("Failed to parse proof login response")?; + verifier + .verify_server(m2) + .map_err(|e| report!("Negotiation failed, server proof mismatch: {}", e))?; + + debug!("Server proof verified"); + + let spd_encrypted = response2 + .get_data("spd") + .context("Failed to get SPD from login response")?; + + let spd_decrypted = Self::decrypt_cbc(&verifier, &spd_encrypted) + .context("Failed to decrypt SPD from login response")?; + let spd: plist::Dictionary = + plist::from_bytes(&spd_decrypted).context("Failed to parse decrypted SPD plist")?; + + self.spd = Some(spd); + + let status = response2 + .get_dict("Status") + .context("Failed to parse proof login response")?; + + debug!("Login step 2 completed"); + + if let Some(plist::Value::String(s)) = status.get("au") { + return Ok(match s.as_str() { + "trustedDeviceSecondaryAuth" => LoginState::NeedsDevice2FA, + "secondaryAuth" => LoginState::NeedsSMS2FA, + unknown => LoginState::NeedsExtraStep(unknown.to_string()), + }); + } + + Ok(LoginState::LoggedIn) + } + + fn create_session_key(usr: &SrpClientVerifier, name: &str) -> Result, Report> { + Ok(Hmac::::new_from_slice(&usr.key())? + .chain_update(name.as_bytes()) + .finalize() + .into_bytes() + .to_vec()) + } + + fn decrypt_cbc(usr: &SrpClientVerifier, data: &[u8]) -> Result, Report> { + let extra_data_key = Self::create_session_key(usr, "extra data key:")?; + let extra_data_iv = Self::create_session_key(usr, "extra data iv:")?; + let extra_data_iv = &extra_data_iv[..16]; + + Ok( + cbc::Decryptor::::new_from_slices(&extra_data_key, extra_data_iv)? + .decrypt_padded_vec_mut::(&data)?, + ) + } } diff --git a/isideload/src/auth/grandslam.rs b/isideload/src/auth/grandslam.rs index f302f1e..686da8b 100644 --- a/isideload/src/auth/grandslam.rs +++ b/isideload/src/auth/grandslam.rs @@ -1,9 +1,16 @@ use plist::Dictionary; -use reqwest::{Certificate, ClientBuilder, header::HeaderValue}; +use plist_macro::pretty_print_dictionary; +use reqwest::{ + Certificate, ClientBuilder, + header::{HeaderMap, HeaderValue}, +}; use rootcause::prelude::*; use tracing::debug; -use crate::anisette::AnisetteClientInfo; +use crate::{ + anisette::AnisetteClientInfo, + util::plist::{PlistDataExtract, plist_to_xml_string}, +}; const APPLE_ROOT: &[u8] = include_bytes!("./apple_root.der"); const URL_BAG: &str = "https://gsa.apple.com/grandslam/GsService2/lookup"; @@ -55,12 +62,61 @@ impl GrandSlam { Ok(self.url_bag.as_ref().unwrap()) } + pub async fn get_url(&mut self, key: &str) -> Result { + let url_bag = self.get_url_bag().await?; + let url = url_bag + .get_string(key) + .context("Unable to find key in URL bag")?; + Ok(url) + } + + pub fn get(&self, url: &str) -> Result { + let builder = self.client.get(url).headers(self.base_headers()?); + + Ok(builder) + } + pub fn post(&self, url: &str) -> Result { let builder = self.client.post(url).headers(self.base_headers()?); Ok(builder) } + pub async fn plist_request( + &self, + url: &str, + body: &Dictionary, + additional_headers: Option, + ) -> Result { + let resp = self + .post(url)? + .headers(additional_headers.unwrap_or_else(|| reqwest::header::HeaderMap::new())) + .body(plist_to_xml_string(body)) + .send() + .await + .context("Failed to send grandslam request")? + .error_for_status() + .context("Received error response from grandslam")? + .text() + .await + .context("Failed to read grandslam response as text")?; + + let dict: Dictionary = plist::from_bytes(resp.as_bytes()) + .context("Failed to parse grandslam response plist") + .attach_with(|| resp.clone())?; + + let response_plist = dict + .get("Response") + .and_then(|v| v.as_dictionary()) + .cloned() + .ok_or_else(|| { + report!("grandslam response missing 'Response'") + .attach(pretty_print_dictionary(&dict)) + })?; + + Ok(response_plist) + } + fn base_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist")); @@ -100,3 +156,31 @@ impl GrandSlam { Ok(client) } } + +pub trait GrandSlamErrorChecker { + fn check_grandslam_error(self) -> Result>; +} + +#[derive(Debug, thiserror::Error)] +pub enum GrandSlamError { + #[error("Auth error {0}: {1}")] + AuthWithMessage(i64, String), +} + +impl GrandSlamErrorChecker for Dictionary { + fn check_grandslam_error(self) -> Result> { + let result = match self.get("Status") { + Some(plist::Value::Dictionary(d)) => d, + _ => &self, + }; + + if result.get_signed_integer("ec").unwrap_or(0) != 0 { + bail!(GrandSlamError::AuthWithMessage( + result.get_signed_integer("ec").unwrap_or(-1), + result.get_str("em").unwrap_or("Unknown error").to_string(), + )) + } + + Ok(self) + } +} diff --git a/isideload/src/lib.rs b/isideload/src/lib.rs index c20b94a..3253576 100644 --- a/isideload/src/lib.rs +++ b/isideload/src/lib.rs @@ -1,3 +1,34 @@ +use rootcause::{ + hooks::{Hooks, context_formatter::ContextFormatterHook}, + prelude::*, +}; + pub mod anisette; pub mod auth; pub mod util; + +struct ReqwestErrorFormatter; + +impl ContextFormatterHook for ReqwestErrorFormatter { + fn display( + &self, + report: rootcause::ReportRef<'_, reqwest::Error, markers::Uncloneable, markers::Local>, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + writeln!(f, "{}", report.format_current_context_unhooked())?; + let mut source = report.current_context_error_source(); + while let Some(s) = source { + writeln!(f, "Caused by: {:?}", s)?; + source = s.source(); + } + Ok(()) + } +} + +pub fn init() -> Result<(), Report> { + Hooks::new() + .context_formatter::(ReqwestErrorFormatter) + .install() + .context("Failed to install error reporting hooks")?; + Ok(()) +} diff --git a/isideload/src/util/plist.rs b/isideload/src/util/plist.rs index a067b5c..b593125 100644 --- a/isideload/src/util/plist.rs +++ b/isideload/src/util/plist.rs @@ -1,4 +1,5 @@ -use plist_macro::{plist_to_xml_bytes, plist_value_to_xml_bytes}; +use plist_macro::{plist_to_xml_bytes, plist_value_to_xml_bytes, pretty_print_dictionary}; +use rootcause::prelude::*; pub fn plist_to_xml_string(p: &plist::Dictionary) -> String { String::from_utf8(plist_to_xml_bytes(p)).unwrap() @@ -7,3 +8,53 @@ pub fn plist_to_xml_string(p: &plist::Dictionary) -> String { pub fn plist_value_to_xml_string(p: &plist::Value) -> String { String::from_utf8(plist_value_to_xml_bytes(p)).unwrap() } + +pub trait PlistDataExtract { + fn get_data(&self, key: &str) -> Result<&[u8], Report>; + fn get_str(&self, key: &str) -> Result<&str, Report>; + fn get_string(&self, key: &str) -> Result; + fn get_signed_integer(&self, key: &str) -> Result; + fn get_dict(&self, key: &str) -> Result<&plist::Dictionary, Report>; +} + +impl PlistDataExtract for plist::Dictionary { + fn get_data(&self, key: &str) -> Result<&[u8], Report> { + self.get(key).and_then(|v| v.as_data()).ok_or_else(|| { + report!("Plist missing data for key '{}'", key).attach(pretty_print_dictionary(self)) + }) + } + + fn get_str(&self, key: &str) -> Result<&str, Report> { + self.get(key).and_then(|v| v.as_string()).ok_or_else(|| { + report!("Plist missing string for key '{}'", key).attach(pretty_print_dictionary(self)) + }) + } + + fn get_string(&self, key: &str) -> Result { + self.get(key) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()) + .ok_or_else(|| { + report!("Plist missing string for key '{}'", key) + .attach(pretty_print_dictionary(self)) + }) + } + + fn get_signed_integer(&self, key: &str) -> Result { + self.get(key) + .and_then(|v| v.as_signed_integer()) + .ok_or_else(|| { + report!("Plist missing signed integer for key '{}'", key) + .attach(pretty_print_dictionary(self)) + }) + } + + fn get_dict(&self, key: &str) -> Result<&plist::Dictionary, Report> { + self.get(key) + .and_then(|v| v.as_dictionary()) + .ok_or_else(|| { + report!("Plist missing dictionary for key '{}'", key) + .attach(pretty_print_dictionary(self)) + }) + } +}