Logging in (with trusted device 2fa only)

This commit is contained in:
nab138
2026-01-25 16:29:07 -05:00
parent 1f61c10731
commit 8be330a6be
10 changed files with 821 additions and 172 deletions

121
Cargo.lock generated
View File

@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -92,7 +103,16 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [ 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]] [[package]]
@@ -107,6 +127,15 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.53" version = "1.2.53"
@@ -150,6 +179,16 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.57" version = "0.1.57"
@@ -236,7 +275,7 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array 0.14.7",
"typenum", "typenum",
] ]
@@ -263,6 +302,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -397,6 +437,16 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -455,6 +505,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -711,6 +770,16 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -731,12 +800,17 @@ dependencies = [
name = "isideload" name = "isideload"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"aes",
"async-trait", "async-trait",
"base64", "base64",
"cbc",
"chrono", "chrono",
"futures-util", "futures-util",
"hex", "hex",
"hmac",
"idevice", "idevice",
"nab138_srp",
"pbkdf2",
"plist", "plist",
"plist-macro", "plist-macro",
"rand", "rand",
@@ -872,6 +946,20 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -881,12 +969,31 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -908,6 +1015,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"

View File

@@ -8,8 +8,9 @@ use tracing_subscriber::FmtSubscriber;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
isideload::init().expect("Failed to initialize error reporting");
let subscriber = FmtSubscriber::builder() let subscriber = FmtSubscriber::builder()
.with_max_level(Level::DEBUG) .with_max_level(Level::INFO)
.finish(); .finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
@@ -31,13 +32,12 @@ async fn main() {
}; };
let account = AppleAccountBuilder::new(apple_id) let account = AppleAccountBuilder::new(apple_id)
.danger_debug(true) .anisette_provider(RemoteV3AnisetteProvider::default().set_serial_number("2".to_string()))
.anisette(RemoteV3AnisetteProvider::default().set_serial_number("2".to_string()))
.login(apple_password, get_2fa_code) .login(apple_password, get_2fa_code)
.await; .await;
match account { match account {
Ok(_account) => println!("Successfully logged in to Apple ID"), 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),
} }
} }

View File

@@ -33,3 +33,8 @@ futures-util = "0.3.31"
serde_json = "1.0.149" serde_json = "1.0.149"
base64 = "0.22.1" base64 = "0.22.1"
hex = "0.4.3" 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"

View File

@@ -1,6 +1,10 @@
pub mod remote_v3; pub mod remote_v3;
use crate::auth::grandslam::GrandSlam; 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 rootcause::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
@@ -11,12 +15,83 @@ pub struct AnisetteClientInfo {
pub user_agent: String, 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<String, String> {
let dt: DateTime<Utc> = 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] #[async_trait::async_trait]
pub trait AnisetteProvider { pub trait AnisetteProvider {
async fn get_anisette_headers( async fn get_anisette_data(&mut self, gs: &mut GrandSlam) -> Result<AnisetteData, Report>;
&mut self,
gs: &mut GrandSlam,
) -> Result<HashMap<String, String>, Report>;
async fn get_client_info(&mut self) -> Result<AnisetteClientInfo, Report>; async fn get_client_info(&mut self) -> Result<AnisetteClientInfo, Report>;
} }

View File

@@ -1,67 +1,25 @@
mod state; mod state;
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use base64::prelude::*; use base64::prelude::*;
use chrono::{DateTime, SubsecRound, Utc}; use chrono::{SubsecRound, Utc};
use plist_macro::plist; use plist_macro::plist;
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use rootcause::prelude::*; use rootcause::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info}; use tracing::{debug, info};
use crate::anisette::remote_v3::state::AnisetteState; 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::auth::grandslam::GrandSlam;
use crate::util::plist::plist_to_xml_string; use crate::util::plist::PlistDataExtract;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
pub const DEFAULT_ANISETTE_V3_URL: &str = "https://ani.sidestore.io"; 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<String, String> {
let dt: DateTime<Utc> = 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 struct RemoteV3AnisetteProvider {
pub state: Option<AnisetteState>, pub state: Option<AnisetteState>,
url: String, url: String,
@@ -117,23 +75,57 @@ impl Default for RemoteV3AnisetteProvider {
#[async_trait::async_trait] #[async_trait::async_trait]
impl AnisetteProvider for RemoteV3AnisetteProvider { impl AnisetteProvider for RemoteV3AnisetteProvider {
async fn get_anisette_headers( async fn get_anisette_data(&mut self, gs: &mut GrandSlam) -> Result<AnisetteData, Report> {
&mut self, let state = self.get_state(gs).await?.clone();
gs: &mut GrandSlam, let adi_pb = state
) -> Result<HashMap<String, String>, Report> { .adi_pb
let state = self.get_state(gs).await?; .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::<AnisetteHeaders>()
.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<AnisetteClientInfo, Report> { async fn get_client_info(&mut self) -> Result<AnisetteClientInfo, Report> {
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() { if self.client_info.is_none() {
let resp = self let resp = self
.client .client
@@ -147,20 +139,20 @@ impl RemoteV3AnisetteProvider {
self.client_info = Some(resp); self.client_info = Some(resp);
} }
debug!("Got client client_info: {:?}", self.client_info); Ok(self.client_info.as_ref().unwrap().clone())
Ok(())
} }
}
impl RemoteV3AnisetteProvider {
async fn get_state(&mut self, gs: &mut GrandSlam) -> Result<&mut AnisetteState, Report> { async fn get_state(&mut self, gs: &mut GrandSlam) -> Result<&mut AnisetteState, Report> {
let state_path = self.config_path.join("state.plist"); let state_path = self.config_path.join("state.plist");
fs::create_dir_all(&self.config_path)?; fs::create_dir_all(&self.config_path)?;
if self.state.is_none() { if self.state.is_none() {
if let Ok(state) = plist::from_file(&state_path) { 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); self.state = Some(state);
} else { } else {
debug!("No existing anisette state found"); info!("No existing anisette state found");
self.state = Some(AnisetteState::new()); self.state = Some(AnisetteState::new());
} }
} }
@@ -177,10 +169,7 @@ impl RemoteV3AnisetteProvider {
Ok(state) Ok(state)
} }
async fn provisioning_headers( async fn provisioning_headers(state: &AnisetteState) -> Result<HeaderMap, Report> {
state: &AnisetteState,
gs: &mut GrandSlam,
) -> Result<HeaderMap, Report> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
"X-Apple-I-MD-LU", "X-Apple-I-MD-LU",
@@ -211,25 +200,14 @@ impl RemoteV3AnisetteProvider {
url: &str, url: &str,
) -> Result<(), Report> { ) -> Result<(), Report> {
info!("Starting provisioning"); info!("Starting provisioning");
let urls = gs.get_url_bag().await?;
let start_provisioning = urls let start_provisioning = gs.get_url("midStartProvisioning").await?;
.get("midStartProvisioning") let end_provisioning = gs.get_url("midFinishProvisioning").await?;
.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 websocket_url = format!("{}/v3/provisioning_session", url) let websocket_url = format!("{}/v3/provisioning_session", url)
.replace("https://", "wss://") .replace("https://", "wss://")
.replace("http://", "ws://"); .replace("http://", "ws://");
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&websocket_url) let (mut ws_stream, _) = tokio_tungstenite::connect_async(&websocket_url).await?;
.await
.context("Failed to connect anisette provisioning socket")?;
loop { loop {
let Some(msg) = ws_stream.next().await else { let Some(msg) = ws_stream.next().await else {
@@ -266,28 +244,18 @@ impl RemoteV3AnisetteProvider {
"Request": {} "Request": {}
}); });
let resp = gs let response = gs
.post(&start_provisioning)? .plist_request(
.headers(Self::provisioning_headers(state, gs).await?) &start_provisioning,
.body(plist_to_xml_string(&body)) &body,
.send() Some(Self::provisioning_headers(state).await?),
)
.await .await
.context("Failed to send start provisioning request")? .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")?;
let resp_plist: plist::Dictionary = plist::from_bytes(resp.as_bytes()) let spim = response
.context("Failed to parse start provisioning response plist")?; .get_str("spim")
.context("Start provisioning response missing spim")?;
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"))?;
ws_stream ws_stream
.send(Message::Text( .send(Message::Text(
@@ -308,39 +276,24 @@ impl RemoteV3AnisetteProvider {
} }
}); });
let resp = gs let response = gs
.post(&end_provisioning)? .plist_request(
.headers(Self::provisioning_headers(state, gs).await?) &end_provisioning,
.body(plist_to_xml_string(&body)) &body,
.send() Some(Self::provisioning_headers(state).await?),
)
.await .await
.context("Failed to send end provisioning request")? .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"
))?;
ws_stream ws_stream
.send(Message::Text( .send(Message::Text(
serde_json::json!({ serde_json::json!({
"ptm": response "ptm": response
.get("ptm") .get_str("ptm")
.and_then(|v| v.as_string()) .context("End provisioning response missing ptm")?,
.ok_or(report!("End provisioning response missing ptm"))?,
"tk": response "tk": response
.get("tk") .get_str("tk")
.and_then(|v| v.as_string()) .context("End provisioning response missing tk")?,
.ok_or(report!("End provisioning response missing tk"))?,
}) })
.to_string() .to_string()
.into(), .into(),
@@ -391,3 +344,19 @@ enum ProvisioningMessage {
StartProvisioningError { message: String }, StartProvisioningError { message: String },
EndProvisioningError { 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,
},
}

View File

@@ -37,7 +37,7 @@ where
Ok(s.try_into().unwrap()) Ok(s.try_into().unwrap())
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AnisetteState { pub struct AnisetteState {
#[serde( #[serde(
serialize_with = "bin_serialize", serialize_with = "bin_serialize",

View File

@@ -1,21 +1,48 @@
use crate::{ use crate::{
anisette::{AnisetteProvider, remote_v3::RemoteV3AnisetteProvider}, anisette::{AnisetteData, AnisetteProvider, remote_v3::RemoteV3AnisetteProvider},
auth::grandslam::GrandSlam, 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 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 struct AppleAccount {
pub email: String, pub email: String,
pub spd: Option<plist::Dictionary>, pub spd: Option<plist::Dictionary>,
pub anisette: Box<dyn AnisetteProvider>, pub anisette_provider: Box<dyn AnisetteProvider>,
pub anisette_data: AnisetteData,
pub grandslam_client: GrandSlam, pub grandslam_client: GrandSlam,
login_state: LoginState,
debug: bool,
} }
pub struct AppleAccountBuilder { pub struct AppleAccountBuilder {
email: String, email: String,
debug: Option<bool>, debug: Option<bool>,
anisette: Option<Box<dyn AnisetteProvider>>, anisette_provider: Option<Box<dyn AnisetteProvider>>,
}
#[derive(Debug)]
pub enum LoginState {
LoggedIn,
NeedsDevice2FA,
NeedsSMS2FA,
NeedsExtraStep(String),
NeedsLogin,
} }
impl AppleAccountBuilder { impl AppleAccountBuilder {
@@ -27,7 +54,7 @@ impl AppleAccountBuilder {
Self { Self {
email: email.to_string(), email: email.to_string(),
debug: None, debug: None,
anisette: None, anisette_provider: None,
} }
} }
@@ -40,11 +67,24 @@ impl AppleAccountBuilder {
self self
} }
pub fn anisette(mut self, anisette: impl AnisetteProvider + 'static) -> Self { pub fn anisette_provider(mut self, anisette_provider: impl AnisetteProvider + 'static) -> Self {
self.anisette = Some(Box::new(anisette)); self.anisette_provider = Some(Box::new(anisette_provider));
self self
} }
/// Build the AppleAccount
///
/// # Errors
/// Returns an error if the reqwest client cannot be built
pub async fn build(self) -> Result<AppleAccount, Report> {
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 /// Build the AppleAccount and log in
/// ///
/// # Arguments /// # Arguments
@@ -60,12 +100,9 @@ impl AppleAccountBuilder {
where where
F: Fn() -> Option<String>, F: Fn() -> Option<String>,
{ {
let debug = self.debug.unwrap_or(false); let mut account = self.build().await?;
let anisette = self account.login(password, two_factor_callback).await?;
.anisette Ok(account)
.unwrap_or_else(|| Box::new(RemoteV3AnisetteProvider::default()));
AppleAccount::login(&self.email, password, two_factor_callback, anisette, debug).await
} }
} }
@@ -78,41 +115,321 @@ impl AppleAccount {
AppleAccountBuilder::new(email) 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 /// Reccomended to use the AppleAccountBuilder instead
pub async fn login( pub async fn new(
email: &str, email: &str,
password: &str, mut anisette_provider: Box<dyn AnisetteProvider>,
two_factor_callback: impl Fn() -> Option<String>,
mut anisette: Box<dyn AnisetteProvider>,
debug: bool, debug: bool,
) -> Result<Self, Report> { ) -> Result<Self, Report> {
info!("Logging in to apple ID: {}", email); info!("Initializing apple account");
if debug { if debug {
warn!("Debug mode enabled: this is a security risk!"); warn!("Debug mode enabled: this is a security risk!");
} }
let client_info = anisette let client_info = anisette_provider
.get_client_info() .get_client_info()
.await .await
.context("Failed to get anisette client info")?; .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 let mut grandslam_client = GrandSlam::new(client_info, debug);
.get_anisette_headers(&mut grandslam_client)
let anisette_data = anisette_provider
.get_anisette_data(&mut grandslam_client)
.await .await
.context("Failed to get anisette headers for login")?; .context("Failed to get anisette data for login")?;
Ok(AppleAccount { Ok(AppleAccount {
email: email.to_string(), email: email.to_string(),
spd: None, spd: None,
anisette, anisette_provider,
anisette_data,
grandslam_client, grandslam_client,
debug,
login_state: LoginState::NeedsLogin,
}) })
} }
pub async fn login(
&mut self,
password: &str,
two_factor_callback: impl Fn() -> Option<String>,
) -> 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<HeaderMap, Report> {
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<LoginState, Report> {
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::<Sha256>::new(&G_2048);
let a: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).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::<hmac::Hmac<Sha256>>(&password_hash, salt, iters as u32, &mut password_buf)
.context("Failed to derive password using PBKDF2")?;
let verifier: SrpClientVerifier<Sha256> = 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<Sha256>, name: &str) -> Result<Vec<u8>, Report> {
Ok(Hmac::<Sha256>::new_from_slice(&usr.key())?
.chain_update(name.as_bytes())
.finalize()
.into_bytes()
.to_vec())
}
fn decrypt_cbc(usr: &SrpClientVerifier<Sha256>, data: &[u8]) -> Result<Vec<u8>, 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::<aes::Aes256>::new_from_slices(&extra_data_key, extra_data_iv)?
.decrypt_padded_vec_mut::<Pkcs7>(&data)?,
)
}
} }

View File

@@ -1,9 +1,16 @@
use plist::Dictionary; 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 rootcause::prelude::*;
use tracing::debug; 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 APPLE_ROOT: &[u8] = include_bytes!("./apple_root.der");
const URL_BAG: &str = "https://gsa.apple.com/grandslam/GsService2/lookup"; const URL_BAG: &str = "https://gsa.apple.com/grandslam/GsService2/lookup";
@@ -55,12 +62,61 @@ impl GrandSlam {
Ok(self.url_bag.as_ref().unwrap()) Ok(self.url_bag.as_ref().unwrap())
} }
pub async fn get_url(&mut self, key: &str) -> Result<String, Report> {
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<reqwest::RequestBuilder, Report> {
let builder = self.client.get(url).headers(self.base_headers()?);
Ok(builder)
}
pub fn post(&self, url: &str) -> Result<reqwest::RequestBuilder, Report> { pub fn post(&self, url: &str) -> Result<reqwest::RequestBuilder, Report> {
let builder = self.client.post(url).headers(self.base_headers()?); let builder = self.client.post(url).headers(self.base_headers()?);
Ok(builder) Ok(builder)
} }
pub async fn plist_request(
&self,
url: &str,
body: &Dictionary,
additional_headers: Option<HeaderMap>,
) -> Result<Dictionary, Report> {
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<reqwest::header::HeaderMap, Report> { fn base_headers(&self) -> Result<reqwest::header::HeaderMap, Report> {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist")); headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist"));
@@ -100,3 +156,31 @@ impl GrandSlam {
Ok(client) Ok(client)
} }
} }
pub trait GrandSlamErrorChecker {
fn check_grandslam_error(self) -> Result<Dictionary, Report<GrandSlamError>>;
}
#[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<Self, Report<GrandSlamError>> {
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)
}
}

View File

@@ -1,3 +1,34 @@
use rootcause::{
hooks::{Hooks, context_formatter::ContextFormatterHook},
prelude::*,
};
pub mod anisette; pub mod anisette;
pub mod auth; pub mod auth;
pub mod util; pub mod util;
struct ReqwestErrorFormatter;
impl ContextFormatterHook<reqwest::Error> 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::<reqwest::Error, _>(ReqwestErrorFormatter)
.install()
.context("Failed to install error reporting hooks")?;
Ok(())
}

View File

@@ -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 { pub fn plist_to_xml_string(p: &plist::Dictionary) -> String {
String::from_utf8(plist_to_xml_bytes(p)).unwrap() 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 { pub fn plist_value_to_xml_string(p: &plist::Value) -> String {
String::from_utf8(plist_value_to_xml_bytes(p)).unwrap() 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<String, Report>;
fn get_signed_integer(&self, key: &str) -> Result<i64, Report>;
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<String, Report> {
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<i64, Report> {
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))
})
}
}