Update to isideload-next

This commit is contained in:
nab138
2026-02-14 18:04:20 -05:00
parent be45c960dc
commit 898de31275
42 changed files with 7601 additions and 847 deletions

View File

@@ -15,8 +15,15 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: "ubuntu-22.04" - platform: "ubuntu-latest"
artifact_name: "minimal"
asset_name: "minimal-linux"
- platform: "windows-latest" - platform: "windows-latest"
artifact_name: "minimal.exe"
asset_name: "minimal-windows.exe"
- platform: "macos-latest"
artifact_name: "minimal"
asset_name: "minimal-macos"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
@@ -29,16 +36,24 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-tauri-${{ hashFiles('Cargo.lock') }} key: ${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-tauri- ${{ runner.os }}-
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Add MSVC to PATH - name: Install Linux dependencies
if: matrix.platform == 'windows-latest' if: runner.os == 'Linux'
uses: ilammy/msvc-dev-cmd@v1 run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
- name: Build - name: Build
run: cargo build --features "vendored-openssl" run: cargo build -p minimal
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: target/debug/${{ matrix.artifact_name }}

5
.gitignore vendored
View File

@@ -1 +1,6 @@
target target
state.plist
*.mobileprovision
*.pem

3544
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["isideload", "examples/minimal"] members = ["examples/minimal","isideload"]
default-members = ["isideload"] default-members = ["isideload"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nab138
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

107
README.md
View File

@@ -2,110 +2,31 @@
[![Build isideload](https://github.com/nab138/isideload/actions/workflows/build.yml/badge.svg)](https://github.com/nab138/isideload/actions/workflows/build.yml) [![Build isideload](https://github.com/nab138/isideload/actions/workflows/build.yml/badge.svg)](https://github.com/nab138/isideload/actions/workflows/build.yml)
**Notice: isideload is currently undergoing a major rewrite (see the `next` branch). Please do not open major pull requests at this time, they will not be reviewed. Please only open issues if the bug is important or the feature request is very small.⚠️**
A Rust library for sideloading iOS applications using an Apple ID. Used in [CrossCode](https://github.com/nab138/CrossCode) and [iloader](https://github.com/nab138/iloader). A Rust library for sideloading iOS applications using an Apple ID. Used in [CrossCode](https://github.com/nab138/CrossCode) and [iloader](https://github.com/nab138/iloader).
This also serves as a rust library for accessing Apple's private developer APIs. See [`developer_session.rs`](isideload/src/developer_session.rs) for details.
## Disclaimer
This package uses private Apple Developer APIs. Use at your own risk.
## Usage ## Usage
To use isideload, add the following to your `Cargo.toml`: **You must call `isideload::init()` at the start of your program to ensure that errors are properly reported.** If you don't, errors related to network requests will not show any details.
```toml A full example is available is in [examples/minimal](examples/minimal/).
[dependencies]
# Make sure to use the latest version
isideload = { version = "0.1.21", features = ["vendored-openssl"] }# Optionally, the vendored feature can be enabled to avoid needing OpenSSL installed on your system.
idevice = { version = "0.1.46", features = ["usbmuxd", "ring"], default-features = false} # Reccomended to disable default features and enable ring to reduce the number of ssl stacks used
```
Then, you can use it like so: ## TODO
```rs Things left todo before the rewrite is considered finished
use std::{env, path::PathBuf, sync::Arc};
use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}; - Proper entitlement handling
use isideload::{ - actually parse macho files and stuff, right now it just uses the bare minimum and applies extra entitlements for livecontainer
AnisetteConfiguration, AppleAccount, SideloadConfiguration, - Reduce duplicate dependencies
developer_session::DeveloperSession, sideload::sideload_app, - partially just need to wait for the rust crypto ecosystem to get through another release cycle
}; - More parallelism and caching for better performance
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
let app_path = PathBuf::from(
args.get(1)
.expect("Please provide the path to the app to install"),
);
let apple_id = args
.get(2)
.expect("Please provide the Apple ID to use for installation");
let apple_password = args.get(3).expect("Please provide the Apple ID password");
// You don't have to use usbmuxd, you can use any IdeviceProvider
let usbmuxd = UsbmuxdConnection::default().await;
if usbmuxd.is_err() {
panic!("Failed to connect to usbmuxd: {:?}", usbmuxd.err());
}
let mut usbmuxd = usbmuxd.unwrap();
let devs = usbmuxd.get_devices().await.unwrap();
if devs.is_empty() {
panic!("No devices found");
}
let provider = devs
.iter()
.next()
.unwrap()
.to_provider(UsbmuxdAddr::from_env_var().unwrap(), "isideload-demo");
// Change the anisette url and such here
// Note that right now only remote anisette servers are supported
let anisette_config = AnisetteConfiguration::default();
let get_2fa_code = || {
let mut code = String::new();
println!("Enter 2FA code:");
std::io::stdin().read_line(&mut code).unwrap();
Ok(code.trim().to_string())
};
let account = AppleAccount::login(
|| Ok((apple_id.to_string(), apple_password.to_string())),
get_2fa_code,
anisette_config,
)
.await
.unwrap();
let dev_session = DeveloperSession::new(Arc::new(account));
// You can change the machine name, store directory (for certs, anisette data, & provision files), and logger
let config = SideloadConfiguration::default().set_machine_name("isideload-demo".to_string());
sideload_app(&provider, &dev_session, app_path, config)
.await
.unwrap()
}
```
See [examples/minimal/src/main.rs](examples/minimal/src/main.rs).
## Licensing ## Licensing
This project is licensed under the MPL-2.0 License. See the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
## Credits ## Credits
- The amazing [idevice](https://github.com/jkcoxson/idevice) crate is used to communicate with the device - The [idevice](https://github.com/jkcoxson/idevice) crate is used to communicate with the device
- A [modified version of apple-platform-rs](https://github.com/nab138/isideload-apple-platform-rs) was used for codesigning, based off [plume-apple-platform-rs](https://github.com/plumeimpactor/plume-apple-platform-rs)
- Packages from [`apple-private-apis`](https://github.com/SideStore/apple-private-apis) were used for authentication, but the original project was left unfinished. To support isideload, `apple-private-apis` was forked and modified to add missing features. With permission from the original developers, the fork was published to crates.io until the official project is published. - [Impactor](https://github.com/khcrysalis/Impactor) was used as a reference for cryptography, codesigning, and provision file parsing.
- [Sideloader](https://github.com/Dadoum/Sideloader) was used as a reference for how apple private developer endpoints work
- [ZSign](https://github.com/zhlynn/zsign) was used for code signing with [custom rust bindings](https://github.com/nab138/zsign-rust)
- [Sideloader](https://github.com/Dadoum/Sideloader) was used as a reference for how the private API endpoints work

View File

@@ -1,5 +1 @@
.zsign_cache /target
keys
*.ipa
state.plist
*.mobileprovision

View File

@@ -2,9 +2,12 @@
name = "minimal" name = "minimal"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
publish = false
[dependencies] [dependencies]
isideload = { path = "../../isideload", features = ["vendored-openssl"] } isideload = { path = "../../isideload" }
idevice = { version = "0.1.46", features = ["usbmuxd", "ring"], default-features = false} plist = "1.8.0"
tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } plist-macro = "0.1.3"
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
idevice = { version = "0.1.52", features = ["usbmuxd"] }

View File

@@ -0,0 +1,5 @@
# minimal
A minimal sideloading CLI to to demonstrate isideload.
Usage: `minimal <appleid@icloud.com> <password> <app>`

View File

@@ -1,24 +1,56 @@
use std::{env, path::PathBuf, sync::Arc}; use std::{env, path::PathBuf};
use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}; use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection};
use isideload::{ use isideload::{
AnisetteConfiguration, AppleAccount, SideloadConfiguration, anisette::remote_v3::RemoteV3AnisetteProvider,
developer_session::DeveloperSession, sideload::sideload_app, auth::apple_account::AppleAccount,
dev::{
certificates::DevelopmentCertificate, developer_session::DeveloperSession,
teams::DeveloperTeam,
},
sideload::{SideloaderBuilder, TeamSelection, builder::MaxCertsBehavior},
}; };
use tracing::Level;
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()
.with_max_level(Level::INFO)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let apple_id = args
.get(1)
.expect("Please provide the Apple ID to use for installation");
let apple_password = args.get(2).expect("Please provide the Apple ID password");
let app_path = PathBuf::from( let app_path = PathBuf::from(
args.get(1) args.get(3)
.expect("Please provide the path to the app to install"), .expect("Please provide the path to the app to install"),
); );
let apple_id = args
.get(2)
.expect("Please provide the Apple ID to use for installation");
let apple_password = args.get(3).expect("Please provide the Apple ID password");
// You don't have to use usbmuxd, you can use any IdeviceProvider let get_2fa_code = || {
let mut code = String::new();
println!("Enter 2FA code:");
std::io::stdin().read_line(&mut code).unwrap();
Some(code.trim().to_string())
};
let account = AppleAccount::builder(apple_id)
.anisette_provider(RemoteV3AnisetteProvider::default().set_serial_number("2".to_string()))
.login(apple_password, get_2fa_code)
.await;
let mut account = account.unwrap();
let dev_session = DeveloperSession::from_account(&mut account)
.await
.expect("Failed to create developer session");
let usbmuxd = UsbmuxdConnection::default().await; let usbmuxd = UsbmuxdConnection::default().await;
if usbmuxd.is_err() { if usbmuxd.is_err() {
panic!("Failed to connect to usbmuxd: {:?}", usbmuxd.err()); panic!("Failed to connect to usbmuxd: {:?}", usbmuxd.err());
@@ -31,36 +63,68 @@ async fn main() {
} }
let provider = devs let provider = devs
.iter() .first()
.next()
.unwrap() .unwrap()
.to_provider(UsbmuxdAddr::from_env_var().unwrap(), "isideload-demo"); .to_provider(UsbmuxdAddr::from_env_var().unwrap(), "isideload-demo");
// Change the anisette url and such here let team_selection_prompt = |teams: &Vec<DeveloperTeam>| {
// Note that right now only remote anisette servers are supported println!("Please select a team:");
let anisette_config = AnisetteConfiguration::default(); for (index, team) in teams.iter().enumerate() {
println!(
let get_2fa_code = || { "{}: {} ({})",
let mut code = String::new(); index + 1,
println!("Enter 2FA code:"); team.name.as_deref().unwrap_or("<Unnamed>"),
std::io::stdin().read_line(&mut code).unwrap(); team.team_id
Ok(code.trim().to_string()) );
}
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let selection = input.trim().parse::<usize>().ok()?;
if selection == 0 || selection > teams.len() {
return None;
}
Some(teams[selection - 1].team_id.clone())
}; };
let account = AppleAccount::login( let cert_selection_prompt = |certs: &Vec<DevelopmentCertificate>| {
|| Ok((apple_id.to_string(), apple_password.to_string())), println!("Maximum number of certificates reached. Please select certificates to revoke:");
get_2fa_code, for (index, cert) in certs.iter().enumerate() {
anisette_config, println!(
) "({}) {}: {}",
.await index + 1,
.unwrap(); cert.name.as_deref().unwrap_or("<Unnamed>"),
cert.machine_name.as_deref().unwrap_or("<No Machine Name>"),
);
}
println!("Enter the numbers of the certificates to revoke, separated by commas:");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let selections: Vec<usize> = input
.trim()
.split(',')
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n > 0 && n <= certs.len())
.collect();
if selections.is_empty() {
return None;
}
Some(
selections
.into_iter()
.map(|n| certs[n - 1].clone())
.collect::<Vec<_>>(),
)
};
let dev_session = DeveloperSession::new(Arc::new(account)); let mut sideloader = SideloaderBuilder::new(dev_session, apple_id.to_string())
.team_selection(TeamSelection::Prompt(team_selection_prompt))
.max_certs_behavior(MaxCertsBehavior::Prompt(cert_selection_prompt))
.machine_name("isideload-minimal".to_string())
.build();
// You can change the machine name, store directory (for certs, anisette data, & provision files), and logger let result = sideloader.install_app(&provider, app_path, true).await;
let config = SideloadConfiguration::default().set_machine_name("isideload-demo".to_string()); match result {
Ok(_) => println!("App installed successfully"),
sideload_app(&provider, &dev_session, app_path, config) Err(e) => panic!("Failed to install app: {:?}", e),
.await }
.unwrap()
} }

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "isideload" name = "isideload"
description = "Sideload iOS/iPadOS applications" description = "Sideload iOS/iPadOS applications"
license = "MPL-2.0" license = "MIT"
authors = ["Nicholas Sharp <nab@nabdev.me>"] authors = ["Nicholas Sharp <nab@nabdev.me>"]
version = "0.1.25" version = "0.2.0"
edition = "2024" edition = "2024"
repository = "https://github.com/nab138/isideload" repository = "https://github.com/nab138/isideload"
documentation = "https://docs.rs/isideload" documentation = "https://docs.rs/isideload"
@@ -11,21 +11,49 @@ keywords = ["ios", "sideload"]
readme = "../README.md" readme = "../README.md"
[features] [features]
default = [] default = ["install", "keyring-storage"]
vendored-openssl = ["openssl/vendored", "zsign-rust/vendored-openssl"] install = ["dep:idevice"]
obfuscate = ["idevice/obfuscate", "icloud_auth/obfuscate", "dep:obfstr"] keyring-storage = ["keyring"]
fs-storage = []
# Unfortunately, dependencies are kinda a mess rn, since this requires a beta version of the srp crate.
# Once that becomes stable, hopefuly duplicate dependencies should clean up.\
# Until then, I will wince in pain every time I see how long the output of cargo tree -d is.
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } idevice = { version = "0.1.52", optional = true, features = ["afc", "installation_proxy"]}
plist = { version = "1.7" } plist = "1.8"
icloud_auth = { version = "0.1.10", package = "nab138_icloud_auth" } plist-macro = "0.1.4"
uuid = { version = "1.17.0", features = ["v4"] } reqwest = { version = "0.13.2", features = ["json", "gzip"] }
zip = { version = "4.3", default-features = false, features = ["deflate"] } thiserror = "2.0.17"
hex = "0.4" async-trait = "0.1.89"
sha1 = "0.10" serde = "1.0.228"
idevice = { version = "0.1.51", features = ["afc", "installation_proxy", "ring"], default-features = false } rand = "0.10.0"
openssl = "0.10" uuid = {version = "1.20.0", features = ["v4"] }
zsign-rust = "0.1.7" tracing = "0.1.44"
thiserror = "2" tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
obfstr = { version = "0.4", optional = true } rootcause = "0.12.0"
reqwest = { version = "0.11.14", features = ["blocking", "json", "default-tls"] } futures-util = "0.3.31"
serde_json = "1.0.149"
base64 = "0.22.1"
hex = "0.4.3"
sha2 = "0.11.0-rc.5"
srp = "0.7.0-rc.1"
pbkdf2 = "0.13.0-rc.9"
hmac = "0.13.0-rc.5"
cbc = { version = "0.2.0-rc.3", features = ["alloc"] }
aes = "0.9.0-rc.4"
aes-gcm = "0.11.0-rc.3"
rsa = { version = "0.10.0-rc.15" }
tokio = { version = "1.49.0", features = ["fs"] }
keyring = { version = "3.6.3", features = ["apple-native", "linux-native-sync-persistent", "windows-native"], optional = true }
x509-certificate = { version = "0.25.0", package = "isideload-x509-certificate" }
rcgen = { version = "0.14.7", default-features = false, features = ["aws_lc_rs", "pem"] }
p12-keystore = "0.2.0"
zip = { version = "7.4", default-features = false, features = ["deflate"] }
apple-codesign = { version = "0.29.0", package = "isideload-apple-codesign", default-features = false}
# There is a bug in rustls-platform-verifier that causes an invalid certificate error with apple's root cert.
# It has been fixed already but I am waiting for a new release before Ic an update the dependency.
# Using native-tls avoids the issue.
[target.'cfg(windows)'.dependencies]
reqwest = { version = "0.13.2", features = ["json", "gzip", "native-tls"] }

View File

@@ -0,0 +1,158 @@
pub mod remote_v3;
use crate::auth::grandslam::GrandSlam;
use plist::Dictionary;
use plist_macro::plist;
use reqwest::header::HeaderMap;
use rootcause::prelude::*;
use serde::Deserialize;
use std::{collections::HashMap, sync::Arc, time::SystemTime};
use tokio::sync::RwLock;
#[derive(Deserialize, Debug, Clone)]
pub struct AnisetteClientInfo {
pub client_info: String,
pub user_agent: String,
}
#[derive(Debug, Clone)]
pub struct AnisetteData {
machine_id: String,
one_time_password: String,
pub routing_info: String,
_device_description: String,
device_unique_identifier: String,
_local_user_id: String,
generated_at: SystemTime,
}
// Some headers don't seem to be required. I guess not including them is technically more efficient soooo
impl AnisetteData {
pub fn get_headers(&self) -> 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(),
// ),
],
)
}
pub fn get_header_map(&self) -> HeaderMap {
let headers_map = self.get_headers();
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) -> Dictionary {
let headers = self.get_headers();
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
}
pub fn needs_refresh(&self) -> bool {
let elapsed = self.generated_at.elapsed().unwrap();
elapsed.as_secs() > 60
}
}
#[async_trait::async_trait]
pub trait AnisetteProvider {
async fn get_anisette_data(&self) -> Result<AnisetteData, Report>;
async fn get_client_info(&mut self) -> Result<AnisetteClientInfo, Report>;
async fn provision(&mut self, gs: Arc<GrandSlam>) -> Result<(), Report>;
fn needs_provisioning(&self) -> Result<bool, Report>;
}
#[derive(Clone)]
pub struct AnisetteDataGenerator {
provider: Arc<RwLock<dyn AnisetteProvider + Send + Sync>>,
data: Option<Arc<AnisetteData>>,
}
impl AnisetteDataGenerator {
pub fn new(provider: Arc<RwLock<dyn AnisetteProvider + Send + Sync>>) -> Self {
AnisetteDataGenerator {
provider,
data: None,
}
}
pub async fn get_anisette_data(
&mut self,
gs: Arc<GrandSlam>,
) -> Result<Arc<AnisetteData>, Report> {
if let Some(data) = &self.data
&& !data.needs_refresh() {
return Ok(data.clone());
}
// trying to avoid locking as write unless necessary to promote concurrency
let provider = self.provider.read().await;
if provider.needs_provisioning()? {
drop(provider);
let mut provider_write = self.provider.write().await;
provider_write.provision(gs).await?;
drop(provider_write);
let provider = self.provider.read().await;
let data = provider.get_anisette_data().await?;
let arc_data = Arc::new(data);
self.data = Some(arc_data.clone());
Ok(arc_data)
} else {
let data = provider.get_anisette_data().await?;
let arc_data = Arc::new(data);
self.data = Some(arc_data.clone());
Ok(arc_data)
}
}
pub async fn get_client_info(&self) -> Result<AnisetteClientInfo, Report> {
let mut provider = self.provider.write().await;
provider.get_client_info().await
}
}

View File

@@ -0,0 +1,391 @@
mod state;
use std::sync::Arc;
use std::time::SystemTime;
use base64::prelude::*;
use plist_macro::plist;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use rootcause::prelude::*;
use serde::Deserialize;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
use crate::SideloadError;
use crate::anisette::remote_v3::state::AnisetteState;
use crate::anisette::{AnisetteClientInfo, AnisetteData, AnisetteProvider};
use crate::auth::grandslam::GrandSlam;
use crate::util::plist::PlistDataExtract;
use crate::util::storage::{SideloadingStorage, new_storage};
use futures_util::{SinkExt, StreamExt};
pub const DEFAULT_ANISETTE_V3_URL: &str = "https://ani.stikstore.app";
pub struct RemoteV3AnisetteProvider {
pub state: Option<AnisetteState>,
url: String,
storage: Box<dyn SideloadingStorage>,
serial_number: String,
client_info: Option<AnisetteClientInfo>,
client: reqwest::Client,
}
impl RemoteV3AnisetteProvider {
/// Create a new RemoteV3AnisetteProvider with the given URL and config path
///
/// # Arguments
/// - `url`: The URL of the remote anisette service
/// - `storage`: The storage backend for anisette data
/// - `serial_number`: The serial number of the device
///
pub fn new(url: &str, storage: Box<dyn SideloadingStorage>, serial_number: String) -> Self {
Self {
state: None,
url: url.to_string(),
storage,
serial_number,
client_info: None,
client: reqwest::ClientBuilder::new()
.danger_accept_invalid_certs(true)
.build()
.unwrap(),
}
}
pub fn set_url(mut self, url: &str) -> RemoteV3AnisetteProvider {
self.url = url.to_string();
self
}
pub fn set_storage(mut self, storage: Box<dyn SideloadingStorage>) -> RemoteV3AnisetteProvider {
self.storage = storage;
self
}
pub fn set_serial_number(mut self, serial_number: String) -> RemoteV3AnisetteProvider {
self.serial_number = serial_number;
self
}
}
impl Default for RemoteV3AnisetteProvider {
fn default() -> Self {
Self::new(
DEFAULT_ANISETTE_V3_URL,
Box::new(new_storage()),
"0".to_string(),
)
}
}
#[async_trait::async_trait]
impl AnisetteProvider for RemoteV3AnisetteProvider {
async fn get_anisette_data(&self) -> Result<AnisetteData, Report> {
let state = self
.state
.as_ref()
.ok_or(SideloadError::AnisetteNotProvisioned)?;
let adi_pb = state
.adi_pb
.as_ref()
.ok_or(SideloadError::AnisetteNotProvisioned)?;
let client_info = self
.client_info
.as_ref()
.ok_or(SideloadError::AnisetteNotProvisioned)?;
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.client_info.clone(),
device_unique_identifier: state.get_device_id(),
_local_user_id: hex::encode(state.get_md_lu()),
generated_at: SystemTime::now(),
};
Ok(data)
}
AnisetteHeaders::GetHeadersError { message } => {
Err(report!("Failed to get anisette headers").attach(message))
}
}
}
async fn get_client_info(&mut self) -> Result<AnisetteClientInfo, Report> {
if self.client_info.is_none() {
let resp = self
.client
.get(format!("{}/v3/client_info", self.url))
.send()
.await?
.error_for_status()?
.json::<AnisetteClientInfo>()
.await?;
self.client_info = Some(resp);
}
Ok(self.client_info.as_ref().unwrap().clone())
}
fn needs_provisioning(&self) -> Result<bool, Report> {
if let Some(state) = &self.state {
Ok(!state.is_provisioned() || self.client_info.is_none())
} else {
Ok(true)
}
}
async fn provision(&mut self, gs: Arc<GrandSlam>) -> Result<(), Report> {
self.get_client_info().await?;
self.get_state(gs).await?;
Ok(())
}
}
impl RemoteV3AnisetteProvider {
async fn get_state(&mut self, gs: Arc<GrandSlam>) -> Result<&mut AnisetteState, Report> {
if self.state.is_none() {
if let Ok(Some(state)) = &self.storage.retrieve_data("anisette_state") {
if let Ok(state) = plist::from_bytes(state) {
info!("Loaded existing anisette state");
self.state = Some(state);
} else {
warn!("Failed to parse existing anisette state, starting fresh");
self.state = Some(AnisetteState::new());
}
} else {
info!("No existing anisette state found");
self.state = Some(AnisetteState::new());
}
}
let state = self.state.as_mut().unwrap();
if !state.is_provisioned() {
info!("Provisioning required...");
Self::provision(state, gs, &self.url)
.await
.context("Failed to provision")?;
}
let buf = Vec::new();
let mut writer = std::io::BufWriter::new(buf);
plist::to_writer_xml(&mut writer, &state).unwrap();
self.storage
.store_data("anisette_state", &writer.into_inner()?)?;
Ok(state)
}
async fn provisioning_headers(state: &AnisetteState) -> Result<HeaderMap, Report> {
let mut headers = HeaderMap::new();
headers.insert(
"X-Apple-I-MD-LU",
HeaderValue::from_str(&hex::encode(state.get_md_lu()))?,
);
// headers.insert(
// "X-Apple-I-Client-Time",
// HeaderValue::from_str(
// &Utc::now()
// .round_subsecs(0)
// .format("%+")
// .to_string()
// .replace("+00:00", "Z"),
// )?,
// );
// headers.insert("X-Apple-I-TimeZone", HeaderValue::from_static("UTC"));
// headers.insert("X-Apple-Locale", HeaderValue::from_static("en_US"));
headers.insert(
"X-Mme-Device-Id",
HeaderValue::from_str(&state.get_device_id())?,
);
Ok(headers)
}
async fn provision(
state: &mut AnisetteState,
gs: Arc<GrandSlam>,
url: &str,
) -> Result<(), Report> {
debug!("Starting provisioning");
let start_provisioning = gs.get_url("midStartProvisioning")?;
let end_provisioning = gs.get_url("midFinishProvisioning")?;
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?;
loop {
let Some(msg) = ws_stream.next().await else {
continue;
};
let msg = msg.context("Failed to read anisette provisioning socket message")?;
if msg.is_close() {
bail!("Anisette provisioning socket closed unexpectedly");
}
let msg = msg
.into_text()
.context("Failed to parse provisioning message")?;
debug!("Received provisioning message: {}", msg);
let provision_msg: ProvisioningMessage =
serde_json::from_str(&msg).context("Unknown provisioning message")?;
match provision_msg {
ProvisioningMessage::GiveIdentifier => {
ws_stream
.send(Message::Text(
serde_json::json!({
"identifier": BASE64_STANDARD.encode(state.keychain_identifier),
})
.to_string()
.into(),
))
.await
.context("Failed to send identifier")?;
}
ProvisioningMessage::GiveStartProvisioningData => {
let body = plist!(dict {
"Header": {},
"Request": {}
});
let response = gs
.plist_request(
&start_provisioning,
&body,
Some(Self::provisioning_headers(state).await?),
)
.await
.context("Failed to send start provisioning request")?;
let spim = response
.get_str("spim")
.context("Start provisioning response missing spim")?;
ws_stream
.send(Message::Text(
serde_json::json!({
"spim": spim,
})
.to_string()
.into(),
))
.await
.context("Failed to send start provisioning data")?;
}
ProvisioningMessage::GiveEndProvisioningData { cpim } => {
let body = plist!(dict {
"Header": {},
"Request": {
"cpim": cpim,
}
});
let response = gs
.plist_request(
&end_provisioning,
&body,
Some(Self::provisioning_headers(state).await?),
)
.await
.context("Failed to send end provisioning request")?;
ws_stream
.send(Message::Text(
serde_json::json!({
"ptm": response
.get_str("ptm")
.context("End provisioning response missing ptm")?,
"tk": response
.get_str("tk")
.context("End provisioning response missing tk")?,
})
.to_string()
.into(),
))
.await
.context("Failed to send start provisioning data")?;
}
ProvisioningMessage::ProvisioningSuccess { adi_pb } => {
state.adi_pb = Some(BASE64_STANDARD.decode(adi_pb)?);
ws_stream.close(None).await?;
info!("Provisioning successful");
break;
}
ProvisioningMessage::Timeout => bail!("Anisette provisioning timed out"),
ProvisioningMessage::InvalidIdentifier => {
bail!("Anisette provisioning failed: invalid identifier")
}
ProvisioningMessage::StartProvisioningError { message } => {
return Err(
report!("Anisette provisioning failed: start provisioning error")
.attach(message),
);
}
ProvisioningMessage::EndProvisioningError { message } => {
return Err(
report!("Anisette provisioning failed: end provisioning error")
.attach(message),
);
}
}
}
Ok(())
}
}
#[derive(Deserialize)]
#[serde(tag = "result")]
enum ProvisioningMessage {
GiveIdentifier,
GiveStartProvisioningData,
GiveEndProvisioningData { cpim: String },
ProvisioningSuccess { adi_pb: String },
Timeout,
InvalidIdentifier,
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,
},
}

View File

@@ -0,0 +1,81 @@
// Serialization/Desieralization borrowed from https://github.com/SideStore/apple-private-apis/blob/master/omnisette/src/remote_anisette_v3.rs
use plist::Data;
use rand::RngExt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest, Sha256};
use uuid::Uuid;
fn bin_serialize<S>(x: &[u8], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_bytes(x)
}
fn bin_serialize_opt<S>(x: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
x.clone().map(Data::new).serialize(s)
}
fn bin_deserialize_opt<'de, D>(d: D) -> Result<Option<Vec<u8>>, D::Error>
where
D: Deserializer<'de>,
{
let s: Option<Data> = Deserialize::deserialize(d)?;
Ok(s.map(|i| i.into()))
}
fn bin_deserialize_16<'de, D>(d: D) -> Result<[u8; 16], D::Error>
where
D: Deserializer<'de>,
{
let s: Data = Deserialize::deserialize(d)?;
let s: Vec<u8> = s.into();
Ok(s.try_into().unwrap())
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AnisetteState {
#[serde(
serialize_with = "bin_serialize",
deserialize_with = "bin_deserialize_16"
)]
pub keychain_identifier: [u8; 16],
#[serde(
serialize_with = "bin_serialize_opt",
deserialize_with = "bin_deserialize_opt"
)]
pub adi_pb: Option<Vec<u8>>,
}
impl Default for AnisetteState {
fn default() -> Self {
AnisetteState {
keychain_identifier: rand::rng().random::<[u8; 16]>(),
adi_pb: None,
}
}
}
impl AnisetteState {
pub fn new() -> AnisetteState {
AnisetteState::default()
}
pub fn is_provisioned(&self) -> bool {
self.adi_pb.is_some()
}
pub fn get_md_lu(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.keychain_identifier);
hasher.finalize().into()
}
pub fn get_device_id(&self) -> String {
Uuid::from_bytes(self.keychain_identifier).to_string()
}
}

View File

@@ -0,0 +1,683 @@
use std::sync::Arc;
use crate::{
anisette::{AnisetteData, AnisetteDataGenerator},
auth::{
builder::AppleAccountBuilder,
grandslam::{GrandSlam, GrandSlamErrorChecker},
},
util::plist::PlistDataExtract,
};
use aes::{
Aes256,
cipher::{block_padding::Pkcs7, consts::U16},
};
use aes_gcm::{AeadInOut, AesGcm, KeyInit, Nonce};
use base64::{Engine, prelude::BASE64_STANDARD};
use cbc::cipher::{BlockModeDecrypt, KeyIvInit};
use hmac::{Hmac, Mac};
use plist::Dictionary;
use plist_macro::plist;
use reqwest::header::{HeaderMap, HeaderValue};
use rootcause::prelude::*;
use sha2::{Digest, Sha256};
use srp::{ClientVerifier, groups::G2048};
use tracing::{debug, info, warn};
pub struct AppleAccount {
pub email: String,
pub spd: Option<plist::Dictionary>,
pub anisette_generator: AnisetteDataGenerator,
pub grandslam_client: Arc<GrandSlam>,
login_state: LoginState,
debug: bool,
}
#[derive(Debug)]
pub enum LoginState {
LoggedIn,
NeedsDevice2FA,
NeedsSMS2FA,
NeedsExtraStep(String),
NeedsLogin,
}
impl AppleAccount {
/// Create a new AppleAccountBuilder with the given email
///
/// # Arguments
/// - `email`: The Apple ID email address
pub fn builder(email: &str) -> AppleAccountBuilder {
AppleAccountBuilder::new(email)
}
/// Build the apple account with the given email
///
/// Reccomended to use the AppleAccountBuilder instead
/// # Arguments
/// - `email`: The Apple ID email address
/// - `anisette_provider`: The anisette provider to use
/// - `debug`: DANGER, If true, accept invalid certificates and enable verbose connection
pub async fn new(
email: &str,
anisette_generator: AnisetteDataGenerator,
debug: bool,
) -> Result<Self, Report> {
if debug {
warn!("Debug mode enabled: this is a security risk!");
}
let client_info = anisette_generator
.get_client_info()
.await
.context("Failed to get anisette client info")?;
let grandslam_client = GrandSlam::new(client_info, debug).await?;
Ok(AppleAccount {
email: email.to_string(),
spd: None,
anisette_generator,
grandslam_client: Arc::new(grandslam_client),
debug,
login_state: LoginState::NeedsLogin,
})
}
/// Log in to the Apple ID account
/// # Arguments
/// - `password`: The Apple ID password
/// - `two_factor_callback`: A callback function that returns the two-factor authentication code
/// # Errors
/// Returns an error if the login fails
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 => {
self.trusted_device_2fa(&two_factor_callback)
.await
.context("Failed to complete trusted device 2FA")?;
debug!("Trusted device 2FA completed, need to login again");
self.login_state = LoginState::NeedsLogin;
}
LoginState::NeedsSMS2FA => {
info!("SMS 2FA required");
self.sms_2fa(&two_factor_callback)
.await
.context("Failed to complete SMS 2FA")?;
debug!("SMS 2FA completed, need to login again");
self.login_state = LoginState::NeedsLogin;
}
LoginState::NeedsExtraStep(s) => {
info!("Additional authentication step required: {}", s);
if self.get_pet().is_err() {
bail!("Additional authentication required: {}", s);
}
self.login_state = LoginState::LoggedIn;
}
LoginState::NeedsLogin => {
debug!("Logging in again...");
self.login_state = self
.login_inner(password)
.await
.context("Failed to login again")?;
}
}
}
}
/// Get the user's first and last name associated with the Apple ID
pub fn get_name(&self) -> Result<(String, String), Report> {
let spd = self
.spd
.as_ref()
.ok_or_else(|| report!("SPD not available, cannot get name"))?;
Ok((spd.get_string("fn")?, spd.get_string("ln")?))
}
fn get_pet(&self) -> Result<String, Report> {
let spd = self
.spd
.as_ref()
.ok_or_else(|| report!("SPD not available, cannot get pet"))?;
let pet = spd
.get_dict("t")?
.get_dict("com.apple.gs.idms.pet")?
.get_string("token")?;
Ok(pet)
}
async fn trusted_device_2fa(
&mut self,
two_factor_callback: impl Fn() -> Option<String>,
) -> Result<(), Report> {
debug!("Trusted device 2FA required");
let anisette_data = self
.anisette_generator
.get_anisette_data(self.grandslam_client.clone())
.await
.context("Failed to get anisette data for 2FA")?;
let request_code_url = self
.grandslam_client
.get_url("trustedDeviceSecondaryAuth")?;
let submit_code_url = self.grandslam_client.get_url("validateCode")?;
self.grandslam_client
.get(&request_code_url)?
.headers(self.build_2fa_headers(&anisette_data).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(&anisette_data).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")?;
Ok(())
}
async fn sms_2fa(
&mut self,
two_factor_callback: impl Fn() -> Option<String>,
) -> Result<(), Report> {
debug!("SMS 2FA required");
let anisette_data = self
.anisette_generator
.get_anisette_data(self.grandslam_client.clone())
.await
.context("Failed to get anisette data for 2FA")?;
let request_code_url = self.grandslam_client.get_url("secondaryAuth")?;
self.grandslam_client
.get_sms(&request_code_url)?
.headers(self.build_2fa_headers(&anisette_data).await?)
.send()
.await
.context("Failed to request SMS 2FA")?
.error_for_status()
.context("SMS 2FA request failed")?;
info!("SMS 2FA request sent");
let code =
two_factor_callback().ok_or_else(|| report!("No 2FA code provided, aborting"))?;
let body = serde_json::json!({
"securityCode": {
"code": code
},
"phoneNumber": {
"id": 1
},
"mode": "sms"
});
let mut headers = self.build_2fa_headers(&anisette_data).await?;
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers.insert(
"Accept",
HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"),
);
let res = self
.grandslam_client
.post("https://gsa.apple.com/auth/verify/phone/securitycode")?
.headers(headers)
.body(body.to_string())
.send()
.await
.context("Failed to submit SMS 2FA code")?;
if !res.status().is_success() {
let status = res.status();
let text = res
.text()
.await
.context("Failed to read SMS 2FA error response text")?;
// try to parse as json, if it fails, just bail with the text
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
&& let Some(service_errors) = json.get("serviceErrors")
&& let Some(first_error) = service_errors.as_array().and_then(|arr| arr.first())
{
let code = first_error
.get("code")
.and_then(|c| c.as_str())
.unwrap_or("unknown");
let title = first_error
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("No title provided");
let message = first_error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("No message provided");
bail!(
"SMS 2FA code submission failed (code {}): {} - {}",
code,
title,
message
);
}
bail!(
"SMS 2FA code submission failed with http status {}: {}",
status,
text
);
};
Ok(())
}
async fn build_2fa_headers(&self, anisette_data: &AnisetteData) -> Result<HeaderMap, Report> {
let mut headers = anisette_data.get_header_map();
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)?,
);
headers.insert(
"X-Apple-I-MD-RINFO",
reqwest::header::HeaderValue::from_str(&anisette_data.routing_info)?,
);
Ok(headers)
}
async fn login_inner(&mut self, password: &str) -> Result<LoginState, Report> {
let anisette_data = self
.anisette_generator
.get_anisette_data(self.grandslam_client.clone())
.await
.context("Failed to get anisette data for login")?;
let gs_service_url = self.grandslam_client.get_url("gsService")?;
debug!("GrandSlam service URL: {}", gs_service_url);
let cpd = anisette_data.get_client_provided_data();
let srp_client = srp::Client::<G2048, Sha256>::new_with_options(false);
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 = 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 mut close_headers = HeaderMap::new();
close_headers.insert("Connection", HeaderValue::from_static("close"));
let response2 = self
.grandslam_client
.plist_request(&gs_service_url, &req2, Some(close_headers))
.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,
"repair" => LoginState::LoggedIn, // Just means that you don't have 2FA set up
unknown => LoginState::NeedsExtraStep(unknown.to_string()),
});
}
Ok(LoginState::LoggedIn)
}
pub async fn get_app_token(&mut self, app: &str) -> Result<AppToken, Report> {
let app = if app.contains("com.apple.gs.") {
app.to_string()
} else {
format!("com.apple.gs.{}", app)
};
let anisette_data = self
.anisette_generator
.get_anisette_data(self.grandslam_client.clone())
.await
.context("Failed to get anisette data for login")?;
let spd = self
.spd
.as_ref()
.ok_or_else(|| report!("SPD data not available, cannot get app token"))?;
let dsid = spd.get_str("adsid").context("Failed to get app token")?;
let auth_token = spd
.get_str("GsIdmsToken")
.context("Failed to get app token")?;
let session_key = spd.get_data("sk").context("Failed to get app token")?;
let c = spd.get_data("c").context("Failed to get app token")?;
let checksum = Hmac::<Sha256>::new_from_slice(session_key)
.unwrap()
.chain_update("apptokens".as_bytes())
.chain_update(dsid.as_bytes())
.chain_update(app.as_bytes())
.finalize()
.into_bytes()
.to_vec();
let gs_service_url = self.grandslam_client.get_url("gsService")?;
let cpd = anisette_data.get_client_provided_data();
let request = plist!(dict {
"Header": {
"Version": "1.0.1"
},
"Request": {
"app": [app.clone()],
"c": c,
"checksum": checksum,
"cpd": cpd,
"o": "apptokens",
"u": dsid,
"t": auth_token
}
});
let resp = self
.grandslam_client
.plist_request(&gs_service_url, &request, None)
.await
.context("Failed to send app token request")?
.check_grandslam_error()
.context("GrandSlam error during app token request")?;
let encrypted_token = resp
.get_data("et")
.context("Failed to get encrypted token")?;
let decrypted_token = Self::decrypt_gcm(encrypted_token, session_key)
.context("Failed to decrypt app token")?;
let token: Dictionary = plist::from_bytes(&decrypted_token)
.context("Failed to parse decrypted app token plist")?;
let status = token
.get_signed_integer("status-code")
.context("Failed to get status code from app token")?;
if status != 200 {
bail!("App token request failed with status code {}", status);
}
let token_dict = token
.get_dict("t")
.context("Failed to get token dictionary from app token")?;
let app_token = token_dict
.get_dict(&app)
.context("Failed to get app token string")?;
let app_token = AppToken {
token: app_token
.get_str("token")
.context("Failed to get app token string")?
.to_string(),
duration: app_token
.get_signed_integer("duration")
.context("Failed to get app token duration")? as u64,
expiry: app_token
.get_signed_integer("expiry")
.context("Failed to get app token expiry")? as u64,
};
Ok(app_token)
}
fn create_session_key(usr: &ClientVerifier<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: &ClientVerifier<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::<Pkcs7>(data)?,
)
}
fn decrypt_gcm(data: &[u8], key: &[u8]) -> Result<Vec<u8>, Report> {
if data.len() < 3 + 16 + 16 {
bail!(
"Encrypted token is too short to be valid (only {} bytes)",
data.len()
);
}
let header = &data[0..3];
if header != b"XYZ" {
bail!(
"Encrypted token is in an unknown format: {}",
String::from_utf8_lossy(header)
);
}
let iv = &data[3..19];
let ciphertext_and_tag = &data[19..];
if key.len() != 32 {
bail!("Session key is not the correct length: {} bytes", key.len());
}
if iv.len() != 16 {
bail!("IV is not the correct length: {} bytes", iv.len());
}
let key = aes_gcm::Key::<AesGcm<Aes256, U16>>::try_from(key)?;
let cipher = AesGcm::<Aes256, U16>::new(&key);
let nonce = Nonce::<U16>::try_from(iv)?;
let mut buf = ciphertext_and_tag.to_vec();
cipher
.decrypt_in_place(&nonce, header, &mut buf)
.map_err(|e| report!("Failed to decrypt gcm: {}", e))?;
Ok(buf)
}
}
impl std::fmt::Display for AppleAccount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Apple Account: ")?;
match self.get_name() {
Ok((first, last)) => write!(f, "{} {} ", first, last),
Err(_) => Ok(()),
}?;
write!(f, "{} ({:?})", self.email, self.login_state)
}
}
#[derive(Debug)]
pub struct AppToken {
pub token: String,
pub duration: u64,
pub expiry: u64,
}

Binary file not shown.

View File

@@ -0,0 +1,81 @@
use std::sync::Arc;
use rootcause::prelude::*;
use tokio::sync::RwLock;
use crate::{
anisette::{AnisetteDataGenerator, AnisetteProvider, remote_v3::RemoteV3AnisetteProvider},
auth::apple_account::AppleAccount,
};
pub struct AppleAccountBuilder {
email: String,
debug: Option<bool>,
anisette_generator: Option<AnisetteDataGenerator>,
}
impl AppleAccountBuilder {
/// Create a new AppleAccountBuilder with the given email
///
/// # Arguments
/// - `email`: The Apple ID email address
pub fn new(email: &str) -> Self {
Self {
email: email.to_string(),
debug: None,
anisette_generator: None,
}
}
/// DANGER Set whether to enable debug mode
///
/// # Arguments
/// - `debug`: If true, accept invalid certificates and enable verbose connection logging
pub fn danger_debug(mut self, debug: bool) -> Self {
self.debug = Some(debug);
self
}
pub fn anisette_provider(
mut self,
anisette_provider: impl AnisetteProvider + Send + Sync + 'static,
) -> Self {
self.anisette_generator = Some(AnisetteDataGenerator::new(Arc::new(RwLock::new(
anisette_provider,
))));
self
}
/// Build the AppleAccount without logging in
///
/// # 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_generator = self.anisette_generator.unwrap_or_else(|| {
AnisetteDataGenerator::new(Arc::new(RwLock::new(RemoteV3AnisetteProvider::default())))
});
AppleAccount::new(&self.email, anisette_generator, debug).await
}
/// Build the AppleAccount and log in
///
/// # Arguments
/// - `password`: The Apple ID password
/// - `two_factor_callback`: A callback function that returns the two-factor authentication code
/// # Errors
/// Returns an error if the reqwest client cannot be built
pub async fn login<F>(
self,
password: &str,
two_factor_callback: F,
) -> Result<AppleAccount, Report>
where
F: Fn() -> Option<String>,
{
let mut account = self.build().await?;
account.login(password, two_factor_callback).await?;
Ok(account)
}
}

View File

@@ -0,0 +1,209 @@
use plist::Dictionary;
use plist_macro::plist_to_xml_string;
use plist_macro::pretty_print_dictionary;
use reqwest::{
Certificate, ClientBuilder,
header::{HeaderMap, HeaderValue},
};
use rootcause::prelude::*;
use tracing::debug;
use crate::{SideloadError, anisette::AnisetteClientInfo, util::plist::PlistDataExtract};
const APPLE_ROOT: &[u8] = include_bytes!("./apple_root.der");
const URL_BAG: &str = "https://gsa.apple.com/grandslam/GsService2/lookup";
pub struct GrandSlam {
pub client: reqwest::Client,
pub client_info: AnisetteClientInfo,
url_bag: Dictionary,
}
impl GrandSlam {
/// Create a new GrandSlam instance
///
/// # Arguments
/// - `client`: The reqwest client to use for requests
pub async fn new(client_info: AnisetteClientInfo, debug: bool) -> Result<Self, Report> {
let client = Self::build_reqwest_client(debug).unwrap();
let base_headers = Self::base_headers(&client_info, false)?;
let url_bag = Self::fetch_url_bag(&client, base_headers).await?;
Ok(Self {
client,
client_info,
url_bag,
})
}
/// Fetch the URL bag from GrandSlam and cache it
pub async fn fetch_url_bag(
client: &reqwest::Client,
base_headers: HeaderMap,
) -> Result<Dictionary, Report> {
debug!("Fetching URL bag from GrandSlam");
let resp = client
.get(URL_BAG)
.headers(base_headers)
.send()
.await
.context("Failed to fetch URL Bag")?
.text()
.await
.context("Failed to read URL Bag response text")?;
let dict: Dictionary =
plist::from_bytes(resp.as_bytes()).context("Failed to parse URL Bag plist")?;
let urls = dict
.get("urls")
.and_then(|v| v.as_dictionary())
.cloned()
.ok_or_else(|| report!("URL Bag plist missing 'urls' dictionary"))?;
Ok(urls)
}
pub fn get_url(&self, key: &str) -> Result<String, Report> {
let url = self
.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(&self.client_info, false)?);
Ok(builder)
}
pub fn get_sms(&self, url: &str) -> Result<reqwest::RequestBuilder, Report> {
let builder = self
.client
.get(url)
.headers(Self::base_headers(&self.client_info, true)?);
Ok(builder)
}
pub fn post(&self, url: &str) -> Result<reqwest::RequestBuilder, Report> {
let builder = self
.client
.post(url)
.headers(Self::base_headers(&self.client_info, false)?);
Ok(builder)
}
pub fn patch(&self, url: &str) -> Result<reqwest::RequestBuilder, Report> {
let builder = self
.client
.patch(url)
.headers(Self::base_headers(&self.client_info, false)?);
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(
client_info: &AnisetteClientInfo,
sms: bool,
) -> Result<reqwest::header::HeaderMap, Report> {
let mut headers = reqwest::header::HeaderMap::new();
if !sms {
headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist"));
headers.insert("Accept", HeaderValue::from_static("text/x-xml-plist"));
}
headers.insert(
"X-Mme-Client-Info",
HeaderValue::from_str(&client_info.client_info)?,
);
headers.insert(
"User-Agent",
HeaderValue::from_str(&client_info.user_agent)?,
);
headers.insert("X-Xcode-Version", HeaderValue::from_static("14.2 (14C18)"));
headers.insert(
"X-Apple-App-Info",
HeaderValue::from_static("com.apple.gs.xcode.auth"),
);
Ok(headers)
}
/// Build a reqwest client with the Apple root certificate
///
/// # Arguments
/// - `debug`: DANGER, If true, accept invalid certificates and enable verbose connection logging
/// # Errors
/// Returns an error if the reqwest client cannot be built
pub fn build_reqwest_client(debug: bool) -> Result<reqwest::Client, Report> {
let cert = Certificate::from_der(APPLE_ROOT)?;
let client = ClientBuilder::new()
.add_root_certificate(cert)
.http1_title_case_headers()
.danger_accept_invalid_certs(debug)
.connection_verbose(debug)
.build()?;
Ok(client)
}
}
pub trait GrandSlamErrorChecker {
fn check_grandslam_error(self) -> Result<Dictionary, Report<SideloadError>>;
}
impl GrandSlamErrorChecker for Dictionary {
fn check_grandslam_error(self) -> Result<Self, Report<SideloadError>> {
let result = match self.get("Status") {
Some(plist::Value::Dictionary(d)) => d,
_ => &self,
};
if result.get_signed_integer("ec").unwrap_or(0) != 0 {
bail!(SideloadError::AuthWithMessage(
result.get_signed_integer("ec").unwrap_or(-1),
result.get_str("em").unwrap_or("Unknown error").to_string(),
))
}
Ok(self)
}
}

View File

@@ -0,0 +1,3 @@
pub mod apple_account;
pub mod builder;
pub mod grandslam;

View File

@@ -0,0 +1,123 @@
use crate::dev::{
app_ids::AppId,
developer_session::DeveloperSession,
device_type::{DeveloperDeviceType, dev_url},
teams::DeveloperTeam,
};
use plist_macro::plist;
use rootcause::prelude::*;
use serde::Deserialize;
use tracing::info;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppGroup {
pub name: Option<String>,
pub identifier: String,
pub application_group: String,
}
#[async_trait::async_trait]
pub trait AppGroupsApi {
fn developer_session(&mut self) -> &mut DeveloperSession;
async fn list_app_groups(
&mut self,
team: &DeveloperTeam,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<Vec<AppGroup>, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
});
let app_groups: Vec<AppGroup> = self
.developer_session()
.send_dev_request(
&dev_url("listApplicationGroups", device_type),
body,
"applicationGroupList",
)
.await
.context("Failed to list developer app groups")?;
Ok(app_groups)
}
async fn add_app_group(
&mut self,
team: &DeveloperTeam,
name: &str,
identifier: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<AppGroup, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"name": name,
"identifier": identifier,
});
let app_group: AppGroup = self
.developer_session()
.send_dev_request(
&dev_url("addApplicationGroup", device_type),
body,
"applicationGroup",
)
.await
.context("Failed to add developer app group")?;
Ok(app_group)
}
async fn assign_app_group(
&mut self,
team: &DeveloperTeam,
app_group: &AppGroup,
app_id: &AppId,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<(), Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"applicationGroups": &app_group.application_group,
"appIdId": &app_id.app_id_id,
});
self.developer_session()
.send_dev_request_no_response(
&dev_url("assignApplicationGroupToAppId", device_type),
body,
)
.await
.context("Failed to assign developer app group")?;
Ok(())
}
async fn ensure_app_group(
&mut self,
team: &DeveloperTeam,
name: &str,
identifier: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<AppGroup, Report> {
let device_type = device_type.into();
let groups = self.list_app_groups(team, device_type.clone()).await?;
let matching_group = groups.iter().find(|g| g.identifier == identifier);
if let Some(group) = matching_group {
Ok(group.clone())
} else {
info!("Adding application group");
let group = self
.add_app_group(team, name, identifier, device_type)
.await?;
Ok(group)
}
}
}
impl AppGroupsApi for DeveloperSession {
fn developer_session(&mut self) -> &mut DeveloperSession {
self
}
}

View File

@@ -0,0 +1,239 @@
use crate::{
dev::{
developer_session::DeveloperSession,
device_type::{DeveloperDeviceType, dev_url},
teams::DeveloperTeam,
},
util::plist::{PlistDataExtract, SensitivePlistAttachment},
};
use plist::{Data, Date, Dictionary, Value};
use plist_macro::plist;
use reqwest::header::HeaderValue;
use rootcause::prelude::*;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppId {
pub app_id_id: String,
pub identifier: String,
pub name: String,
pub features: Dictionary,
pub expiration_date: Option<Date>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListAppIdsResponse {
pub app_ids: Vec<AppId>,
pub max_quantity: Option<u64>,
pub available_quantity: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub encoded_profile: Data,
pub filename: String,
pub provisioning_profile_id: String,
pub name: String,
pub status: String,
pub r#type: String,
pub distribution_method: String,
pub pro_pro_platorm: Option<String>,
#[serde(rename = "UUID")]
pub uuid: String,
pub date_expire: Date,
pub managing_app: Option<String>,
pub app_id_id: String,
pub is_template_profile: bool,
pub is_team_profile: Option<bool>,
pub is_free_provisioning_profile: Option<bool>,
}
#[async_trait::async_trait]
pub trait AppIdsApi {
fn developer_session(&mut self) -> &mut DeveloperSession;
async fn add_app_id(
&mut self,
team: &DeveloperTeam,
name: &str,
identifier: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<AppId, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"identifier": identifier,
"name": name,
});
let app_id: AppId = self
.developer_session()
.send_dev_request(&dev_url("addAppId", device_type), body, "appId")
.await
.context("Failed to add developer app ID")?;
Ok(app_id)
}
async fn list_app_ids(
&mut self,
team: &DeveloperTeam,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<ListAppIdsResponse, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
});
let response: Value = self
.developer_session()
.send_dev_request_no_response(&dev_url("listAppIds", device_type), body)
.await
.context("Failed to list developer app IDs")?
.into();
let app_ids: ListAppIdsResponse = plist::from_value(&response).map_err(|e| {
report!("Failed to deserialize app id response: {:?}", e).attach(
SensitivePlistAttachment::new(
response
.as_dictionary()
.unwrap_or(&Dictionary::new())
.clone(),
),
)
})?;
Ok(app_ids)
}
async fn update_app_id(
&mut self,
team: &DeveloperTeam,
app_id: &AppId,
features: Dictionary,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<AppId, Report> {
let mut body = plist!(dict {
"teamId": &team.team_id,
"appIdId": &app_id.app_id_id
});
for (key, value) in features {
body.insert(key.clone(), value.clone());
}
Ok(self
.developer_session()
.send_dev_request(&dev_url("updateAppId", device_type), body, "appId")
.await
.context("Failed to update developer app ID")?)
}
async fn delete_app_id(
&mut self,
team: &DeveloperTeam,
app_id: &AppId,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<(), Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"appIdId": &app_id.app_id_id,
});
self.developer_session()
.send_dev_request_no_response(&dev_url("deleteAppId", device_type), body)
.await
.context("Failed to delete developer app ID")?;
Ok(())
}
async fn download_team_provisioning_profile(
&mut self,
team: &DeveloperTeam,
app_id: &AppId,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<Profile, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"appIdId": &app_id.app_id_id,
});
let response: Profile = self
.developer_session()
.send_dev_request(
&dev_url("downloadTeamProvisioningProfile", device_type),
body,
"provisioningProfile",
)
.await
.context("Failed to download provisioning profile")?;
Ok(response)
}
async fn add_increased_memory_limit(
&mut self,
team: &DeveloperTeam,
app_id: &AppId,
) -> Result<(), Report> {
let dev_session = self.developer_session();
let mut headers = dev_session.get_headers().await?;
headers.insert(
"Content-Type",
HeaderValue::from_static("application/vnd.api+json"),
);
headers.insert(
"Accept",
HeaderValue::from_static("application/vnd.api+json"),
);
dev_session
.get_grandslam_client()
.patch(&format!(
"https://developerservices2.apple.com/services/v1/bundleIds/{}",
app_id.app_id_id
))?
.headers(headers)
.body(format!(
"{{\"data\":{{\"relationships\":{{\"bundleIdCapabilities\":{{\"data\":[{{\"relationships\":{{\"capability\":{{\"data\":{{\"id\":\"INCREASED_MEMORY_LIMIT\",\"type\":\"capabilities\"}}}}}},\"type\":\"bundleIdCapabilities\",\"attributes\":{{\"settings\":[],\"enabled\":true}}}}]}}}},\"id\":\"{}\",\"attributes\":{{\"hasExclusiveManagedCapabilities\":false,\"teamId\":\"{}\",\"bundleType\":\"bundle\",\"identifier\":\"{}\",\"seedId\":\"{}\",\"name\":\"{}\"}},\"type\":\"bundleIds\"}}}}",
app_id.app_id_id, team.team_id, app_id.identifier, team.team_id, app_id.name
))
.send()
.await.context("Failed to request increased memory entitlement")?
.error_for_status().context("Failed to add increased memory entitlement")?;
Ok(())
}
}
impl AppIdsApi for DeveloperSession {
fn developer_session(&mut self) -> &mut DeveloperSession {
self
}
}
impl AppId {
pub async fn ensure_group_feature(
&mut self,
dev_session: &mut DeveloperSession,
team: &DeveloperTeam,
) -> Result<(), Report> {
let app_group_feature_enabled = self.features.get_bool("APG3427HIY")?;
if !app_group_feature_enabled {
let body = plist!(dict {
"APG3427HIY": true,
});
let new_features = dev_session
.update_app_id(team, self, body, None)
.await?
.features;
self.features = new_features;
}
Ok(())
}
}

View File

@@ -0,0 +1,181 @@
use crate::dev::{
developer_session::DeveloperSession,
device_type::{DeveloperDeviceType, dev_url},
teams::DeveloperTeam,
};
use plist::{Data, Date};
use plist_macro::plist;
use rootcause::prelude::*;
use serde::Deserialize;
use uuid::Uuid;
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DevelopmentCertificate {
pub name: Option<String>,
pub certificate_id: Option<String>,
pub serial_number: Option<String>,
pub machine_id: Option<String>,
pub machine_name: Option<String>,
pub cert_content: Option<Data>,
pub certificate_platform: Option<String>,
pub certificate_type: Option<CertificateType>,
pub status: Option<String>,
pub status_code: Option<i64>,
pub expiration_date: Option<Date>,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CertificateType {
pub certificate_type_display_id: Option<String>,
pub name: Option<String>,
pub platform: Option<String>,
pub permission_type: Option<String>,
pub distribution_type: Option<String>,
pub distribution_method: Option<String>,
pub owner_type: Option<String>,
pub days_overlap: Option<i64>,
pub max_active_certs: Option<i64>,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CertRequest {
pub cert_request_id: String,
}
// the automatic debug implementation spams the console with the cert content bytes
impl std::fmt::Debug for DevelopmentCertificate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("DevelopmentCertificate");
s.field("name", &self.name)
.field("certificate_id", &self.certificate_id)
.field("serial_number", &self.serial_number)
.field("machine_id", &self.machine_id)
.field("machine_name", &self.machine_name)
.field(
"cert_content",
&self
.cert_content
.as_ref()
.map(|c| format!("Some([{} bytes])", c.as_ref().len()))
.unwrap_or("None".to_string()),
)
.field("certificate_platform", &self.certificate_platform)
.field("certificate_type", &self.certificate_type)
.field("status", &self.status)
.field("status_code", &self.status_code)
.field("expiration_date", &self.expiration_date)
.finish()
}
}
#[async_trait::async_trait]
pub trait CertificatesApi {
fn developer_session(&mut self) -> &mut DeveloperSession;
async fn list_all_development_certs(
&mut self,
team: &DeveloperTeam,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<Vec<DevelopmentCertificate>, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
});
let certs: Vec<DevelopmentCertificate> = self
.developer_session()
.send_dev_request(
&dev_url("listAllDevelopmentCerts", device_type),
body,
"certificates",
)
.await
.context("Failed to list development certificates")?;
Ok(certs)
}
async fn list_ios_certs(
&mut self,
team: &DeveloperTeam,
) -> Result<Vec<DevelopmentCertificate>, Report> {
let certs = self
.list_all_development_certs(team, DeveloperDeviceType::Ios)
.await?;
Ok(certs
.into_iter()
.filter(|c| {
if let Some(platform) = &c.certificate_platform {
platform.to_lowercase() == "ios"
} else if let Some(cert_type) = &c.certificate_type {
if let Some(platform) = &cert_type.platform {
platform.to_lowercase() == "ios"
} else {
// I don't know how consistently these field is populated because apple apis are stupid, and I don't want to break things so just assume
true
}
} else {
true
}
})
.collect())
}
async fn revoke_development_cert(
&mut self,
team: &DeveloperTeam,
serial_number: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<(), Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"serialNumber": serial_number,
});
self.developer_session()
.send_dev_request_no_response(
&dev_url("revokeDevelopmentCert", device_type),
Some(body),
)
.await
.context("Failed to revoke development certificate")?;
Ok(())
}
async fn submit_development_csr(
&mut self,
team: &DeveloperTeam,
csr_content: String,
machine_name: String,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<CertRequest, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"csrContent": csr_content,
"machineName": machine_name,
"machineId": Uuid::new_v4().to_string().to_uppercase(),
});
let cert: CertRequest = self
.developer_session()
.send_dev_request(
&dev_url("submitDevelopmentCSR", device_type),
body,
"certRequest",
)
.await
.context("Failed to submit development CSR")?;
Ok(cert)
}
}
impl CertificatesApi for DeveloperSession {
fn developer_session(&mut self) -> &mut DeveloperSession {
self
}
}

View File

@@ -0,0 +1,183 @@
use std::sync::Arc;
use plist::Dictionary;
use plist_macro::{plist, plist_to_xml_string};
use reqwest::header::{HeaderMap, HeaderValue};
use rootcause::prelude::*;
use serde::de::DeserializeOwned;
use tracing::{error, warn};
use uuid::Uuid;
use crate::{
SideloadError,
anisette::AnisetteDataGenerator,
auth::{
apple_account::{AppToken, AppleAccount},
grandslam::GrandSlam,
},
util::plist::PlistDataExtract,
};
pub use super::app_groups::*;
pub use super::app_ids::*;
pub use super::certificates::*;
pub use super::device_type::DeveloperDeviceType;
pub use super::devices::*;
pub use super::teams::*;
pub struct DeveloperSession {
token: AppToken,
adsid: String,
client: Arc<GrandSlam>,
anisette_generator: AnisetteDataGenerator,
}
impl DeveloperSession {
pub fn new(
token: AppToken,
adsid: String,
client: Arc<GrandSlam>,
anisette_generator: AnisetteDataGenerator,
) -> Self {
DeveloperSession {
token,
adsid,
client,
anisette_generator,
}
}
pub async fn from_account(account: &mut AppleAccount) -> Result<Self, Report> {
let token = account
.get_app_token("xcode.auth")
.await
.context("Failed to get xcode token from Apple account")?;
let spd = account
.spd
.as_ref()
.ok_or_else(|| report!("SPD not available, cannot get adsid"))?;
Ok(DeveloperSession::new(
token,
spd.get_string("adsid")?,
account.grandslam_client.clone(),
account.anisette_generator.clone(),
))
}
pub async fn get_headers(&mut self) -> Result<HeaderMap, Report> {
let mut headers = self
.anisette_generator
.get_anisette_data(self.client.clone())
.await?
.get_header_map();
headers.insert(
"X-Apple-GS-Token",
HeaderValue::from_str(&self.token.token)?,
);
headers.insert("X-Apple-I-Identity-Id", HeaderValue::from_str(&self.adsid)?);
Ok(headers)
}
pub fn get_grandslam_client(&self) -> Arc<GrandSlam> {
self.client.clone()
}
async fn send_dev_request_internal(
&mut self,
url: &str,
body: impl Into<Option<Dictionary>>,
) -> Result<(Dictionary, Option<SideloadError>), Report> {
let body = body.into().unwrap_or_else(Dictionary::new);
let base = plist!(dict {
"clientId": "XABBG36SBA",
"protocolVersion": "QH65B2",
"requestId": Uuid::new_v4().to_string().to_uppercase(),
"userLocale": ["en_US"],
});
let body = base.into_iter().chain(body.into_iter()).collect();
let text = self
.client
.post(url)?
.body(plist_to_xml_string(&body))
.headers(self.get_headers().await?)
.send()
.await?
.error_for_status()
.context("Developer request failed")?
.text()
.await
.context("Failed to read developer request response text")?;
let dict: Dictionary = plist::from_bytes(text.as_bytes())
.context("Failed to parse developer request plist")?;
// All this error handling is here to ensure that:
// 1. We always warn/log errors from the server even if it returns the expected data
// 2. We return server errors if the expected data is missing
// 3. We return parsing errors if there is no server error but the expected data is missing
let response_code = dict.get("resultCode").and_then(|v| v.as_signed_integer());
let mut server_error: Option<SideloadError> = None;
if let Some(code) = response_code {
if code != 0 {
let result_string = dict
.get("resultString")
.and_then(|v| v.as_string())
.unwrap_or("No error message given.");
let user_string = dict
.get("userString")
.and_then(|v| v.as_string())
.unwrap_or(result_string);
server_error = Some(SideloadError::DeveloperError(code, user_string.to_string()));
error!(
"Developer request returned error code {}: {} ({})",
code, user_string, result_string
);
}
} else {
warn!("No resultCode in developer request response");
}
Ok((dict, server_error))
}
pub async fn send_dev_request<T: DeserializeOwned>(
&mut self,
url: &str,
body: impl Into<Option<Dictionary>>,
response_key: &str,
) -> Result<T, Report> {
let (dict, server_error) = self.send_dev_request_internal(url, body).await?;
let result: Result<T, _> = dict.get_struct(response_key);
if result.is_err()
&& let Some(err) = server_error
{
bail!(err);
}
Ok(result.context("Failed to extract developer request result")?)
}
pub async fn send_dev_request_no_response(
&mut self,
url: &str,
body: impl Into<Option<Dictionary>>,
) -> Result<Dictionary, Report> {
let (dict, server_error) = self.send_dev_request_internal(url, body).await?;
if let Some(err) = server_error {
bail!(err);
}
Ok(dict)
}
}

View File

@@ -0,0 +1,29 @@
#[derive(Debug, Clone)]
pub enum DeveloperDeviceType {
Any,
Ios,
Tvos,
Watchos,
}
impl DeveloperDeviceType {
pub fn url_segment(&self) -> &'static str {
match self {
DeveloperDeviceType::Any => "",
DeveloperDeviceType::Ios => "ios/",
DeveloperDeviceType::Tvos => "tvos/",
DeveloperDeviceType::Watchos => "watchos/",
}
}
}
pub fn dev_url(endpoint: &str, device_type: impl Into<Option<DeveloperDeviceType>>) -> String {
format!(
"https://developerservices2.apple.com/services/QH65B2/{}{}.action?clientId=XABBG36SBA",
device_type
.into()
.unwrap_or(DeveloperDeviceType::Ios)
.url_segment(),
endpoint,
)
}

View File

@@ -0,0 +1,90 @@
use crate::dev::{
developer_session::DeveloperSession,
device_type::{DeveloperDeviceType, dev_url},
teams::DeveloperTeam,
};
use plist_macro::plist;
use rootcause::prelude::*;
use serde::Deserialize;
use tracing::info;
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DeveloperDevice {
pub name: Option<String>,
pub device_id: Option<String>,
pub device_number: String,
pub status: Option<String>,
}
#[async_trait::async_trait]
pub trait DevicesApi {
fn developer_session(&mut self) -> &mut DeveloperSession;
async fn list_devices(
&mut self,
team: &DeveloperTeam,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<Vec<DeveloperDevice>, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
});
let devices: Vec<DeveloperDevice> = self
.developer_session()
.send_dev_request(&dev_url("listDevices", device_type), body, "devices")
.await
.context("Failed to list developer devices")?;
Ok(devices)
}
async fn add_device(
&mut self,
team: &DeveloperTeam,
name: &str,
udid: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<DeveloperDevice, Report> {
let body = plist!(dict {
"teamId": &team.team_id,
"name": name,
"deviceNumber": udid,
});
let device: DeveloperDevice = self
.developer_session()
.send_dev_request(&dev_url("addDevice", device_type), body, "device")
.await
.context("Failed to add developer device")?;
Ok(device)
}
// TODO: This can be skipped if we know the device is already registered
/// Check if the device is a development device, and add it if not
async fn ensure_device_registered(
&mut self,
team: &DeveloperTeam,
name: &str,
udid: &str,
device_type: impl Into<Option<DeveloperDeviceType>> + Send,
) -> Result<(), Report> {
let device_type = device_type.into();
let devices = self.list_devices(team, device_type.clone()).await?;
if !devices.iter().any(|d| d.device_number == udid) {
info!("Registering development device");
self.add_device(team, name, udid, device_type).await?;
}
info!("Device is a development device");
Ok(())
}
}
impl DevicesApi for DeveloperSession {
fn developer_session(&mut self) -> &mut DeveloperSession {
self
}
}

7
isideload/src/dev/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod app_groups;
pub mod app_ids;
pub mod certificates;
pub mod developer_session;
pub mod device_type;
pub mod devices;
pub mod teams;

View File

@@ -0,0 +1,36 @@
use crate::dev::{
developer_session::DeveloperSession,
device_type::{DeveloperDeviceType::*, dev_url},
};
use rootcause::prelude::*;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DeveloperTeam {
pub name: Option<String>,
pub team_id: String,
pub r#type: Option<String>,
pub status: Option<String>,
}
#[async_trait::async_trait]
pub trait TeamsApi {
fn developer_session(&mut self) -> &mut DeveloperSession;
async fn list_teams(&mut self) -> Result<Vec<DeveloperTeam>, Report> {
let response: Vec<DeveloperTeam> = self
.developer_session()
.send_dev_request(&dev_url("listTeams", Any), None, "teams")
.await
.context("Failed to list developer teams")?;
Ok(response)
}
}
impl TeamsApi for DeveloperSession {
fn developer_session(&mut self) -> &mut DeveloperSession {
self
}
}

View File

@@ -1,129 +1,59 @@
pub mod application;
pub mod bundle;
pub mod certificate;
pub mod developer_session;
pub mod device;
pub mod sideload;
use std::io::Error as IOError;
pub use icloud_auth::{AnisetteConfiguration, AppleAccount};
use developer_session::DeveloperTeam;
use idevice::IdeviceError; use idevice::IdeviceError;
use thiserror::Error as ThisError; use rootcause::{
use zsign_rust::ZSignError; hooks::{Hooks, context_formatter::ContextFormatterHook},
prelude::*,
};
pub mod anisette;
pub mod auth;
pub mod dev;
pub mod sideload;
pub mod util;
#[derive(Debug, thiserror::Error)]
pub enum SideloadError {
#[error("Auth error {0}: {1}")]
AuthWithMessage(i64, String),
#[error("Plist parse error: {0}")]
PlistParseError(String),
#[error("Failed to get anisette data, anisette not provisioned")]
AnisetteNotProvisioned,
#[error("Developer error {0}: {1}")]
DeveloperError(i64, String),
#[derive(Debug, ThisError)]
pub enum Error {
#[error("Authentication error {0}: {1}")]
Auth(i64, String),
#[error("Developer session error {0}: {1}")]
DeveloperSession(i64, String),
#[error("Error: {0}")]
Generic(String),
#[error("Failed to parse: {0}")]
Parse(String),
#[error("Invalid bundle: {0}")] #[error("Invalid bundle: {0}")]
InvalidBundle(String), InvalidBundle(String),
#[error("Certificate error: {0}")]
Certificate(String),
#[error(transparent)]
Filesystem(#[from] IOError),
#[error(transparent)] #[error(transparent)]
IdeviceError(#[from] IdeviceError), IdeviceError(#[from] IdeviceError),
#[error(transparent)]
ZSignError(#[from] ZSignError),
#[error(transparent)]
ICloudError(#[from] icloud_auth::Error),
} }
pub trait SideloadLogger: Send + Sync { // The default reqwest error formatter sucks and provides no info
fn log(&self, message: &str); struct ReqwestErrorFormatter;
fn error(&self, error: &Error);
}
pub struct DefaultLogger; impl ContextFormatterHook<reqwest::Error> for ReqwestErrorFormatter {
fn display(
impl SideloadLogger for DefaultLogger { &self,
fn log(&self, message: &str) { report: rootcause::ReportRef<'_, reqwest::Error, markers::Uncloneable, markers::Local>,
println!("{message}"); f: &mut std::fmt::Formatter<'_>,
} ) -> std::fmt::Result {
writeln!(f, "{}", report.format_current_context_unhooked())?;
fn error(&self, error: &Error) { let mut source = report.current_context_error_source();
eprintln!("Error: {}", error); while let Some(s) = source {
} writeln!(f, "Caused by: {:?}", s)?;
} source = s.source();
/// Sideload configuration options.
pub struct SideloadConfiguration<'a> {
/// An arbitrary machine name to appear on the certificate (e.x. "YCode")
pub machine_name: String,
/// Logger for reporting progress and errors
pub logger: &'a dyn SideloadLogger,
/// Directory used to store intermediate artifacts (profiles, certs, etc.). This directory will not be cleared at the end.
pub store_dir: std::path::PathBuf,
/// Whether or not to revoke the certificate immediately after installation
pub revoke_cert: bool,
/// Whether or not to add the increased memory limit entitlement to the app
pub add_increased_memory_limit: bool,
}
impl Default for SideloadConfiguration<'_> {
fn default() -> Self {
SideloadConfiguration::new()
}
}
impl<'a> SideloadConfiguration<'a> {
pub fn new() -> Self {
SideloadConfiguration {
machine_name: "isideload".to_string(),
logger: &DefaultLogger,
store_dir: std::env::current_dir().unwrap(),
revoke_cert: false,
add_increased_memory_limit: false,
} }
} Ok(())
pub fn set_machine_name(mut self, machine_name: String) -> Self {
self.machine_name = machine_name;
self
}
pub fn set_logger(mut self, logger: &'a dyn SideloadLogger) -> Self {
self.logger = logger;
self
}
pub fn set_store_dir(mut self, store_dir: std::path::PathBuf) -> Self {
self.store_dir = store_dir;
self
}
pub fn set_revoke_cert(mut self, revoke_cert: bool) -> Self {
self.revoke_cert = revoke_cert;
self
}
pub fn set_add_increased_memory_limit(mut self, add: bool) -> Self {
self.add_increased_memory_limit = add;
self
} }
} }
#[cfg(feature = "obfuscate")] pub fn init() -> Result<(), Report> {
#[macro_export] Hooks::new()
macro_rules! obf { .context_formatter::<reqwest::Error, _>(ReqwestErrorFormatter)
($lit:literal) => { .install()
&obfstr::obfstring!($lit) .context("Failed to install error reporting hooks")?;
}; Ok(())
}
#[cfg(not(feature = "obfuscate"))]
#[macro_export]
macro_rules! obf {
($lit:literal) => {
&$lit.to_string()
};
} }

View File

@@ -0,0 +1,288 @@
// This file was made using https://github.com/Dadoum/Sideloader as a reference.
// I'm planning on redoing this later to better handle entitlements, extensions, etc, but it will do for now
use crate::SideloadError;
use crate::dev::app_ids::{AppId, AppIdsApi};
use crate::dev::developer_session::DeveloperSession;
use crate::dev::teams::DeveloperTeam;
use crate::sideload::bundle::Bundle;
use crate::sideload::cert_identity::CertificateIdentity;
use rootcause::option_ext::OptionExt;
use rootcause::prelude::*;
use std::fs::File;
use std::path::PathBuf;
use tokio::io::AsyncWriteExt;
use tracing::info;
use zip::ZipArchive;
pub struct Application {
pub bundle: Bundle,
//pub temp_path: PathBuf,
}
impl Application {
pub fn new(path: PathBuf) -> Result<Self, Report> {
if !path.exists() {
bail!(SideloadError::InvalidBundle(
"Application path does not exist".to_string(),
));
}
let mut bundle_path = path.clone();
//let mut temp_path = PathBuf::new();
if path.is_file() {
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir
.join(path.file_name().unwrap().to_string_lossy().to_string() + "_extracted");
if temp_path.exists() {
std::fs::remove_dir_all(&temp_path)
.context("Failed to remove existing temporary directory")?;
}
std::fs::create_dir_all(&temp_path).context("Failed to create temporary directory")?;
let file = File::open(&path).context("Failed to open application archive")?;
let mut archive =
ZipArchive::new(file).context("Failed to open application archive")?;
archive
.extract(&temp_path)
.context("Failed to extract application archive")?;
let payload_folder = temp_path.join("Payload");
if payload_folder.exists() && payload_folder.is_dir() {
let app_dirs: Vec<_> = std::fs::read_dir(&payload_folder)
.context("Failed to read Payload directory")?
.filter_map(Result::ok)
.filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.collect();
if app_dirs.len() == 1 {
bundle_path = app_dirs[0].path();
} else if app_dirs.is_empty() {
bail!(SideloadError::InvalidBundle(
"No .app directory found in Payload".to_string(),
));
} else {
bail!(SideloadError::InvalidBundle(
"Multiple .app directories found in Payload".to_string(),
));
}
} else {
bail!(SideloadError::InvalidBundle(
"No Payload directory found in the application archive".to_string(),
));
}
}
let bundle = Bundle::new(bundle_path)?;
Ok(Application {
bundle, /*temp_path*/
})
}
pub fn get_special_app(&self) -> Option<SpecialApp> {
let bundle_id = self.bundle.bundle_identifier().unwrap_or("");
let special_app = match bundle_id {
"com.rileytestut.AltStore" => Some(SpecialApp::AltStore),
"com.SideStore.SideStore" => Some(SpecialApp::SideStore),
_ => None,
};
if special_app.is_some() {
return special_app;
}
if self
.bundle
.frameworks()
.iter()
.any(|f| f.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore")
{
return Some(SpecialApp::SideStoreLc);
}
if bundle_id == "com.kdt.livecontainer" {
return Some(SpecialApp::LiveContainer);
}
None
}
pub fn main_bundle_id(&self) -> Result<String, Report> {
let str = self
.bundle
.bundle_identifier()
.ok_or_report()
.context("Failed to get main bundle identifier")?
.to_string();
Ok(str)
}
pub fn main_app_name(&self) -> Result<String, Report> {
let str = self
.bundle
.bundle_name()
.ok_or_report()
.context("Failed to get main app name")?
.to_string();
Ok(str)
}
pub fn update_bundle_id(
&mut self,
main_app_bundle_id: &str,
main_app_id_str: &str,
) -> Result<(), Report> {
let extensions = self.bundle.app_extensions_mut();
for ext in extensions.iter_mut() {
if let Some(id) = ext.bundle_identifier() {
if !(id.starts_with(main_app_bundle_id) && id.len() > main_app_bundle_id.len()) {
bail!(SideloadError::InvalidBundle(format!(
"Extension {} is not part of the main app bundle identifier: {}",
ext.bundle_name().unwrap_or("Unknown"),
id
)));
} else {
ext.set_bundle_identifier(&format!(
"{}{}",
main_app_id_str,
&id[main_app_bundle_id.len()..]
));
}
}
}
self.bundle.set_bundle_identifier(main_app_id_str);
Ok(())
}
pub async fn register_app_ids(
&self,
//mode: &ExtensionsBehavior,
dev_session: &mut DeveloperSession,
team: &DeveloperTeam,
) -> Result<Vec<AppId>, Report> {
let extension_refs: Vec<_> = self.bundle.app_extensions().iter().collect();
let mut bundles_with_app_id = vec![&self.bundle];
bundles_with_app_id.extend(extension_refs);
let list_app_ids_response = dev_session
.list_app_ids(team, None)
.await
.context("Failed to list app IDs for the developer team")?;
let app_ids_to_register = bundles_with_app_id
.iter()
.filter(|bundle| {
let bundle_id = bundle.bundle_identifier().unwrap_or("");
!list_app_ids_response
.app_ids
.iter()
.any(|app_id| app_id.identifier == bundle_id)
})
.collect::<Vec<_>>();
if let Some(available) = list_app_ids_response.available_quantity
&& app_ids_to_register.len() > available.try_into().unwrap()
{
bail!(
"Not enough available app IDs. {} are required, but only {} are available.",
app_ids_to_register.len(),
available
);
}
for bundle in app_ids_to_register {
let id = bundle.bundle_identifier().unwrap_or("");
let name = bundle.bundle_name().unwrap_or("");
dev_session.add_app_id(team, name, id, None).await?;
}
let list_app_id_response = dev_session.list_app_ids(team, None).await?;
let app_ids: Vec<_> = list_app_id_response
.app_ids
.into_iter()
.filter(|app_id| {
bundles_with_app_id
.iter()
.any(|bundle| app_id.identifier == bundle.bundle_identifier().unwrap_or(""))
})
.collect();
info!("Registered app IDs");
Ok(app_ids)
}
pub async fn apply_special_app_behavior(
&mut self,
special: &Option<SpecialApp>,
group_identifier: &str,
cert: &CertificateIdentity,
) -> Result<(), Report> {
if special.is_none() {
return Ok(());
}
let special = special.as_ref().unwrap();
if matches!(
special,
SpecialApp::SideStoreLc | SpecialApp::SideStore | SpecialApp::AltStore
) {
info!("Injecting certificate for {}", special);
self.bundle.app_info.insert(
"ALTAppGroups".to_string(),
plist::Value::Array(vec![plist::Value::String(group_identifier.to_string())]),
);
let target_bundle =
match special {
SpecialApp::SideStoreLc => self.bundle.frameworks_mut().iter_mut().find(|fw| {
fw.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore"
}),
_ => Some(&mut self.bundle),
};
if let Some(target_bundle) = target_bundle {
target_bundle.app_info.insert(
"ALTCertificateID".to_string(),
plist::Value::String(cert.get_serial_number()),
);
let p12_bytes = cert
.as_p12(&cert.machine_id)
.await
.context("Failed to encode cert as p12")?;
let alt_cert_path = target_bundle.bundle_dir.join("ALTCertificate.p12");
let mut file = tokio::fs::File::create(&alt_cert_path)
.await
.context("Failed to create ALTCertificate.p12")?;
file.write_all(&p12_bytes)
.await
.context("Failed to write ALTCertificate.p12")?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpecialApp {
SideStore,
SideStoreLc,
LiveContainer,
AltStore,
StikStore,
}
// impl display
impl std::fmt::Display for SpecialApp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SpecialApp::SideStore => write!(f, "SideStore"),
SpecialApp::SideStoreLc => write!(f, "SideStore+LiveContainer"),
SpecialApp::LiveContainer => write!(f, "LiveContainer"),
SpecialApp::AltStore => write!(f, "AltStore"),
SpecialApp::StikStore => write!(f, "StikStore"),
}
}
}

View File

@@ -0,0 +1,160 @@
use std::fmt::Display;
use crate::{
dev::{
certificates::DevelopmentCertificate, developer_session::DeveloperSession,
teams::DeveloperTeam,
},
sideload::sideloader::Sideloader,
util::storage::SideloadingStorage,
};
/// Configuration for selecting a developer team during sideloading
///
/// If there is only one team, it will be selected automatically regardless of this setting.
/// If there are multiple teams, the behavior will depend on this setting.
pub enum TeamSelection {
/// Select the first team automatically
First,
/// Prompt the user to select a team
Prompt(fn(&Vec<DeveloperTeam>) -> Option<String>),
}
impl Display for TeamSelection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TeamSelection::First => write!(f, "first team"),
TeamSelection::Prompt(_) => write!(f, "prompting for team"),
}
}
}
/// Behavior when the maximum number of development certificates is reached
pub enum MaxCertsBehavior {
/// If the maximum number of certificates is reached, revoke certs until it is possible to create a new certificate
Revoke,
/// If the maximum number of certificates is reached, return an error instead of creating a new certificate
Error,
/// If the maximum number of certificates is reached, prompt the user to select which certificates to revoke until it is possible to create a new certificate
Prompt(fn(&Vec<DevelopmentCertificate>) -> Option<Vec<DevelopmentCertificate>>),
}
/// The actual behavior choices for extensions (non-prompt variants)
pub enum ExtensionsBehaviorChoice {
/// Use the main app id/profile for all sub-bundles
ReuseMain,
/// Create separate app ids/profiles for each sub-bundle
RegisterAll,
/// Remove all sub-bundles
RemoveExtensions,
}
// /// Behavior used when an app contains sub bundles
// pub enum ExtensionsBehavior {
// /// Use the main app id/profile for all sub-bundles
// ReuseMain,
// /// Create separate app ids/profiles for each sub-bundle
// RegisterAll,
// /// Remove all sub-bundles
// RemoveExtensions,
// /// Prompt the user to choose one of the above behaviors
// Prompt(fn(&Vec<String>) -> ExtensionsBehaviorChoice),
// }
// impl From<ExtensionsBehaviorChoice> for ExtensionsBehavior {
// fn from(choice: ExtensionsBehaviorChoice) -> Self {
// match choice {
// ExtensionsBehaviorChoice::ReuseMain => ExtensionsBehavior::ReuseMain,
// ExtensionsBehaviorChoice::RegisterAll => ExtensionsBehavior::RegisterAll,
// ExtensionsBehaviorChoice::RemoveExtensions => ExtensionsBehavior::RemoveExtensions,
// }
// }
// }
pub struct SideloaderBuilder {
developer_session: DeveloperSession,
apple_email: String,
team_selection: Option<TeamSelection>,
max_certs_behavior: Option<MaxCertsBehavior>,
//extensions_behavior: Option<ExtensionsBehavior>,
storage: Option<Box<dyn SideloadingStorage>>,
machine_name: Option<String>,
delete_app_after_install: bool,
}
impl SideloaderBuilder {
/// Create a new `SideloaderBuilder` with the provided Apple developer session and Apple ID email.
pub fn new(developer_session: DeveloperSession, apple_email: String) -> Self {
SideloaderBuilder {
team_selection: None,
storage: None,
developer_session,
machine_name: None,
apple_email,
max_certs_behavior: None,
delete_app_after_install: true,
// extensions_behavior: None,
}
}
/// Set the team selection behavior
///
/// See [`TeamSelection`] for details.
pub fn team_selection(mut self, selection: TeamSelection) -> Self {
self.team_selection = Some(selection);
self
}
/// Set the storage backend for sideloading data
///
/// An implementation using `keyring` is provided in the `keyring-storage` feature.
/// See [`SideloadingStorage`] for details.
///
/// If not set, either keyring storage or in memory storage (not persisted across runs) will be used depending on if the `keyring-storage` feature is enabled.
pub fn storage(mut self, storage: Box<dyn SideloadingStorage>) -> Self {
self.storage = Some(storage);
self
}
/// Set the machine name to use for the development certificate
///
/// This has no bearing on functionality but can be useful for users to identify where a certificate came from.
/// If not set, a default name of "isideload" will be used.
pub fn machine_name(mut self, machine_name: String) -> Self {
self.machine_name = Some(machine_name);
self
}
/// Set the behavior for when the maximum number of development certificates is reached
pub fn max_certs_behavior(mut self, behavior: MaxCertsBehavior) -> Self {
self.max_certs_behavior = Some(behavior);
self
}
/// Set whether to delete the signed app from the temporary storage after installation. Defaults to `true`.
pub fn delete_app_after_install(mut self, delete: bool) -> Self {
self.delete_app_after_install = delete;
self
}
// pub fn extensions_behavior(mut self, behavior: ExtensionsBehavior) -> Self {
// self.extensions_behavior = Some(behavior);
// self
// }
/// Build the `Sideloader` instance with the provided configuration
pub fn build(self) -> Sideloader {
Sideloader::new(
self.developer_session,
self.apple_email,
self.team_selection.unwrap_or(TeamSelection::First),
self.max_certs_behavior.unwrap_or(MaxCertsBehavior::Error),
self.machine_name.unwrap_or_else(|| "isideload".to_string()),
self.storage
.unwrap_or_else(|| Box::new(crate::util::storage::new_storage())),
// self.extensions_behavior
// .unwrap_or(ExtensionsBehavior::RegisterAll),
self.delete_app_after_install,
)
}
}

View File

@@ -0,0 +1,234 @@
// This file was made using https://github.com/Dadoum/Sideloader as a reference.
// I'm planning on redoing this later to better handle entitlements, extensions, etc, but it will do for now
use plist::{Dictionary, Value};
use rootcause::prelude::*;
use std::{
fs,
path::{Path, PathBuf},
};
use crate::SideloadError;
#[derive(Debug, Clone)]
pub struct Bundle {
pub app_info: Dictionary,
pub bundle_dir: PathBuf,
app_extensions: Vec<Bundle>,
frameworks: Vec<Bundle>,
_libraries: Vec<String>,
}
impl Bundle {
pub fn new(bundle_dir: PathBuf) -> Result<Self, Report> {
let mut bundle_path = bundle_dir;
// Remove trailing slash/backslash
if let Some(path_str) = bundle_path.to_str()
&& (path_str.ends_with('/') || path_str.ends_with('\\'))
{
bundle_path = PathBuf::from(&path_str[..path_str.len() - 1]);
}
let info_plist_path = bundle_path.join("Info.plist");
assert_bundle(
info_plist_path.exists(),
&format!("No Info.plist here: {}", info_plist_path.display()),
)?;
let plist_data = fs::read(&info_plist_path).context(SideloadError::InvalidBundle(
"Failed to read Info.plist".to_string(),
))?;
let app_info = plist::from_bytes(&plist_data).context(SideloadError::InvalidBundle(
"Failed to parse Info.plist".to_string(),
))?;
// Load app extensions from PlugIns directory
let plug_ins_dir = bundle_path.join("PlugIns");
let app_extensions = if plug_ins_dir.exists() {
fs::read_dir(&plug_ins_dir)
.context(SideloadError::InvalidBundle(
"Failed to read PlugIns directory".to_string(),
))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& entry.path().join("Info.plist").exists()
})
.filter_map(|entry| Bundle::new(entry.path()).ok())
.collect()
} else {
Vec::new()
};
// Load frameworks from Frameworks directory
let frameworks_dir = bundle_path.join("Frameworks");
let frameworks = if frameworks_dir.exists() {
fs::read_dir(&frameworks_dir)
.context(SideloadError::InvalidBundle(
"Failed to read Frameworks directory".to_string(),
))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& entry.path().join("Info.plist").exists()
})
.filter_map(|entry| Bundle::new(entry.path()).ok())
.collect()
} else {
Vec::new()
};
// Find all .dylib files in the bundle directory (recursive)
let libraries = find_dylibs(&bundle_path, &bundle_path)?;
Ok(Bundle {
app_info,
bundle_dir: bundle_path,
app_extensions,
frameworks,
_libraries: libraries,
})
}
pub fn set_bundle_identifier(&mut self, id: &str) {
self.app_info.insert(
"CFBundleIdentifier".to_string(),
Value::String(id.to_string()),
);
}
pub fn bundle_identifier(&self) -> Option<&str> {
self.app_info
.get("CFBundleIdentifier")
.and_then(|v| v.as_string())
}
pub fn bundle_name(&self) -> Option<&str> {
self.app_info
.get("CFBundleName")
.and_then(|v| v.as_string())
}
pub fn app_extensions(&self) -> &[Bundle] {
&self.app_extensions
}
pub fn app_extensions_mut(&mut self) -> &mut [Bundle] {
&mut self.app_extensions
}
pub fn frameworks(&self) -> &[Bundle] {
&self.frameworks
}
pub fn frameworks_mut(&mut self) -> &mut [Bundle] {
&mut self.frameworks
}
pub fn write_info(&self) -> Result<(), Report> {
let info_plist_path = self.bundle_dir.join("Info.plist");
plist::to_file_binary(&info_plist_path, &self.app_info).context(
SideloadError::InvalidBundle("Failed to write Info.plist".to_string()),
)?;
Ok(())
}
fn from_dylib_path(dylib_path: PathBuf) -> Self {
Self {
app_info: Dictionary::new(),
bundle_dir: dylib_path,
app_extensions: Vec::new(),
frameworks: Vec::new(),
_libraries: Vec::new(),
}
}
fn collect_dylib_bundles(&self) -> Vec<Bundle> {
self._libraries
.iter()
.map(|relative| Self::from_dylib_path(self.bundle_dir.join(relative)))
.collect()
}
fn collect_nested_bundles_into(&self, bundles: &mut Vec<Bundle>) {
for bundle in &self.app_extensions {
bundles.push(bundle.clone());
bundle.collect_nested_bundles_into(bundles);
}
for bundle in &self.frameworks {
bundles.push(bundle.clone());
bundle.collect_nested_bundles_into(bundles);
}
}
pub fn collect_nested_bundles(&self) -> Vec<Bundle> {
let mut bundles = Vec::new();
self.collect_nested_bundles_into(&mut bundles);
bundles.extend(self.collect_dylib_bundles());
bundles
}
pub fn collect_bundles_sorted(&self) -> Vec<Bundle> {
let mut bundles = self.collect_nested_bundles();
bundles.push(self.clone());
bundles.sort_by_key(|b| b.bundle_dir.components().count());
bundles.reverse();
bundles
}
}
fn assert_bundle(condition: bool, msg: &str) -> Result<(), Report> {
if !condition {
bail!(SideloadError::InvalidBundle(msg.to_string()))
} else {
Ok(())
}
}
fn find_dylibs(dir: &Path, bundle_root: &Path) -> Result<Vec<String>, Report> {
let mut libraries = Vec::new();
fn collect_dylibs(
dir: &Path,
bundle_root: &Path,
libraries: &mut Vec<String>,
) -> Result<(), Report> {
let entries = fs::read_dir(dir).context(SideloadError::InvalidBundle(format!(
"Failed to read directory {}",
dir.display()
)))?;
for entry in entries {
let entry = entry.context(SideloadError::InvalidBundle(
"Failed to read directory entry".to_string(),
))?;
let path = entry.path();
let file_type = entry.file_type().context(SideloadError::InvalidBundle(
"Failed to get file type".to_string(),
))?;
if file_type.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.ends_with(".dylib")
{
// Get relative path from bundle root
if let Ok(relative_path) = path.strip_prefix(bundle_root)
&& let Some(relative_str) = relative_path.to_str()
{
libraries.push(relative_str.to_string());
}
}
} else if file_type.is_dir() {
collect_dylibs(&path, bundle_root, libraries)?;
}
}
Ok(())
}
collect_dylibs(dir, bundle_root, &mut libraries)?;
Ok(libraries)
}

View File

@@ -0,0 +1,340 @@
use apple_codesign::{
SigningSettings,
cryptography::{InMemoryPrivateKey, PrivateKey},
};
use hex::ToHex;
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_RSA_SHA256};
use rootcause::prelude::*;
use rsa::{
RsaPrivateKey,
pkcs1::EncodeRsaPublicKey,
pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding},
};
use sha2::{Digest, Sha256};
use tracing::{error, info};
use x509_certificate::CapturedX509Certificate;
use crate::{
SideloadError,
dev::{
certificates::{CertificatesApi, DevelopmentCertificate},
developer_session::DeveloperSession,
teams::DeveloperTeam,
},
sideload::builder::MaxCertsBehavior,
util::storage::SideloadingStorage,
};
pub struct CertificateIdentity {
pub machine_id: String,
pub machine_name: String,
pub certificate: CapturedX509Certificate,
pub private_key: RsaPrivateKey,
pub signing_key: InMemoryPrivateKey,
}
impl CertificateIdentity {
// This implementation was "heavily inspired" by Impactor (https://github.com/khcrysalis/Impactor/blob/main/crates/plume_core/src/utils/certificate.rs)
// It's a little messy and I will clean it up when the rust crypto ecosystem gets through it's next release cycle and I can reduce duplicate dependencies
/// Exports the certificate and private key as a PKCS#12 archive
/// If you plan to import into SideStore/AltStore, use the machine id as the password
pub async fn as_p12(&self, password: &str) -> Result<Vec<u8>, Report> {
let cert_der = self.certificate.encode_der()?;
let key_der = self.private_key.to_pkcs8_der()?.as_bytes().to_vec();
let cert = p12_keystore::Certificate::from_der(&cert_der)
.map_err(|e| report!("Failed to parse certificate: {:?}", e))?;
let local_key_id = {
let mut hasher = Sha256::new();
hasher.update(&key_der);
let hash = hasher.finalize();
hash[..8].to_vec()
};
let key_chain = p12_keystore::PrivateKeyChain::new(key_der, local_key_id, vec![cert]);
let mut keystore = p12_keystore::KeyStore::new();
keystore.add_entry(
"isideload",
p12_keystore::KeyStoreEntry::PrivateKeyChain(key_chain),
);
let writer = keystore.writer(password);
let p12 = writer.write().context("Failed to write PKCS#12 archive")?;
Ok(p12)
}
pub fn get_serial_number(&self) -> String {
let serial: String = self.certificate.serial_number_asn1().encode_hex();
serial.trim_start_matches('0').to_string().to_uppercase()
}
pub async fn retrieve(
machine_name: &str,
apple_email: &str,
developer_session: &mut DeveloperSession,
team: &DeveloperTeam,
storage: &dyn SideloadingStorage,
max_certs_behavior: &MaxCertsBehavior,
) -> Result<Self, Report> {
let pr = Self::retrieve_private_key(apple_email, storage).await?;
let signing_key = Self::build_signing_key(&pr)?;
let found = Self::find_matching(&pr, machine_name, developer_session, team).await;
if let Ok(Some((cert, x509_cert))) = found {
info!("Found matching certificate");
return Ok(Self {
machine_id: cert.machine_id.clone().unwrap_or_default(),
machine_name: cert.machine_name.clone().unwrap_or_default(),
certificate: x509_cert,
private_key: pr,
signing_key,
});
}
if let Err(e) = found {
error!("Failed to check for matching certificate: {:?}", e);
}
info!("Requesting new certificate");
let (cert, x509_cert) = Self::request_certificate(
&pr,
machine_name.to_string(),
developer_session,
team,
max_certs_behavior,
)
.await?;
info!("Successfully obtained certificate");
Ok(Self {
machine_id: cert.machine_id.clone().unwrap_or_default(),
machine_name: cert.machine_name.clone().unwrap_or_default(),
certificate: x509_cert,
private_key: pr,
signing_key,
})
}
async fn retrieve_private_key(
apple_email: &str,
storage: &dyn SideloadingStorage,
) -> Result<RsaPrivateKey, Report> {
let mut hasher = Sha256::new();
hasher.update(apple_email.as_bytes());
let email_hash = hex::encode(hasher.finalize());
let private_key = storage.retrieve_data(&format!("{}/key", email_hash))?;
if let Some(priv_key) = private_key {
info!("Using existing private key from storage");
return Ok(RsaPrivateKey::from_pkcs8_der(&priv_key)?);
}
let mut rng = rand::rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048)?;
storage.store_data(
&format!("{}/key", email_hash),
private_key.to_pkcs8_der()?.as_bytes(),
)?;
Ok(private_key)
}
async fn find_matching(
private_key: &RsaPrivateKey,
machine_name: &str,
developer_session: &mut DeveloperSession,
team: &DeveloperTeam,
) -> Result<Option<(DevelopmentCertificate, CapturedX509Certificate)>, Report> {
let public_key_der = private_key
.to_public_key()
.to_pkcs1_der()?
.as_bytes()
.to_vec();
for cert in developer_session
.list_ios_certs(team)
.await?
.iter()
.filter(|c| {
c.cert_content.is_some()
&& c.machine_name.as_deref().unwrap_or("") == machine_name
&& c.machine_id.is_some()
})
{
let x509_cert =
CapturedX509Certificate::from_der(cert.cert_content.as_ref().unwrap().as_ref())?;
if public_key_der == x509_cert.public_key_data().as_ref() {
return Ok(Some((cert.clone(), x509_cert)));
}
}
Ok(None)
}
async fn request_certificate(
private_key: &RsaPrivateKey,
machine_name: String,
developer_session: &mut DeveloperSession,
team: &DeveloperTeam,
max_certs_behavior: &MaxCertsBehavior,
) -> Result<(DevelopmentCertificate, CapturedX509Certificate), Report> {
let csr = Self::build_csr(private_key).context("Failed to generate CSR")?;
let mut i = 0;
let mut existing_certs: Option<Vec<DevelopmentCertificate>> = None;
while i < 4 {
i += 1;
let result = developer_session
.submit_development_csr(team, csr.clone(), machine_name.clone(), None)
.await;
match result {
Ok(request) => {
let apple_certs = developer_session.list_ios_certs(team).await?;
let apple_cert = apple_certs
.iter()
.find(|c| c.certificate_id == Some(request.cert_request_id.clone()))
.ok_or_else(|| {
report!("Failed to find certificate after submitting CSR")
})?;
let x509_cert = CapturedX509Certificate::from_der(
apple_cert
.cert_content
.as_ref()
.ok_or_else(|| report!("Certificate content missing"))?
.as_ref(),
)?;
return Ok((apple_cert.clone(), x509_cert));
}
Err(e) => {
let error = e
.iter_reports()
.find_map(|node| node.downcast_current_context::<SideloadError>());
if let Some(SideloadError::DeveloperError(code, _)) = error {
if *code == 7460 {
if existing_certs.is_none() {
existing_certs = Some(
developer_session
.list_ios_certs(team)
.await?
.iter()
.filter(|c| c.serial_number.is_some())
.cloned()
.collect(),
);
}
Self::revoke_others(
developer_session,
team,
max_certs_behavior,
SideloadError::DeveloperError(
*code,
"Maximum number of certificates reached".to_string(),
),
existing_certs.as_mut().unwrap(),
)
.await?;
} else {
return Err(e);
}
}
}
};
}
Err(report!("Reached max attempts to request certificate"))
}
fn build_csr(private_key: &RsaPrivateKey) -> Result<String, Report> {
let mut params = CertificateParams::new(vec![])?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, "US");
dn.push(DnType::StateOrProvinceName, "STATE");
dn.push(DnType::LocalityName, "LOCAL");
dn.push(DnType::OrganizationName, "ORGNIZATION");
dn.push(DnType::CommonName, "CN");
params.distinguished_name = dn;
let subject_key = KeyPair::from_pkcs8_pem_and_sign_algo(
&private_key.to_pkcs8_pem(LineEnding::LF)?,
&PKCS_RSA_SHA256,
)?;
Ok(params.serialize_request(&subject_key)?.pem()?)
}
fn build_signing_key(private_key: &RsaPrivateKey) -> Result<InMemoryPrivateKey, Report> {
let pkcs8 = private_key.to_pkcs8_der()?;
Ok(InMemoryPrivateKey::from_pkcs8_der(pkcs8.as_bytes())?)
}
async fn revoke_others(
developer_session: &mut DeveloperSession,
team: &DeveloperTeam,
max_certs_behavior: &MaxCertsBehavior,
error: SideloadError,
existing_certs: &mut Vec<DevelopmentCertificate>,
) -> Result<(), Report> {
match max_certs_behavior {
MaxCertsBehavior::Revoke => {
if let Some(cert) = existing_certs.pop() {
info!(
"Revoking certificate with name: {:?} ({:?})",
cert.name, cert.machine_name
);
developer_session
.revoke_development_cert(team, &cert.serial_number.unwrap(), None)
.await?;
Ok(())
} else {
error!("No more certificates to revoke but still hitting max certs error");
Err(error.into())
}
}
MaxCertsBehavior::Error => Err(error.into()),
MaxCertsBehavior::Prompt(prompt_fn) => {
let certs_to_revoke = prompt_fn(existing_certs);
if certs_to_revoke.is_none() {
error!("User did not select any certificates to revoke");
return Err(error.into());
}
for cert in certs_to_revoke.unwrap() {
info!(
"Revoking certificate with name: {}",
cert.machine_name
.unwrap_or(cert.machine_id.unwrap_or_default())
);
let serial_number = cert.serial_number.clone();
developer_session
.revoke_development_cert(team, &cert.serial_number.unwrap(), None)
.await?;
existing_certs.retain(|c| c.serial_number != serial_number);
}
Ok(())
}
}
}
pub fn setup_signing_settings<'a>(
&'a self,
settings: &mut SigningSettings<'a>,
) -> Result<(), Report> {
settings.set_signing_key(
self.signing_key.as_key_info_signer(),
self.certificate.clone(),
);
settings.chain_apple_certificates();
settings.set_team_id_from_signing_certificate();
Ok(())
}
}

View File

@@ -0,0 +1,95 @@
use idevice::{
IdeviceService, afc::AfcClient, installation_proxy::InstallationProxyClient,
provider::IdeviceProvider,
};
use plist_macro::plist;
use rootcause::prelude::*;
use crate::SideloadError as Error;
use std::pin::Pin;
use std::{future::Future, path::Path};
/// Installs an ***already signed*** app onto your device.
/// To sign and install an app, see [`crate::sideload::sideload_app`]
pub async fn install_app(
provider: &impl IdeviceProvider,
app_path: &Path,
progress_callback: impl Fn(u64),
) -> Result<(), Report> {
let mut afc_client = AfcClient::connect(provider)
.await
.map_err(Error::IdeviceError)?;
let dir = format!(
"PublicStaging/{}",
app_path.file_name().unwrap().to_string_lossy()
);
afc_upload_dir(&mut afc_client, app_path, &dir).await?;
let mut instproxy_client = InstallationProxyClient::connect(provider)
.await
.map_err(Error::IdeviceError)?;
let options = plist!(dict {
"PackageType": "Developer"
});
instproxy_client
.install_with_callback(
dir,
Some(plist::Value::Dictionary(options)),
async |(percentage, _)| {
progress_callback(percentage);
},
(),
)
.await
.map_err(Error::IdeviceError)?;
Ok(())
}
fn afc_upload_dir<'a>(
afc_client: &'a mut AfcClient,
path: &'a Path,
afc_path: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), Report>> + Send + 'a>> {
Box::pin(async move {
let entries = std::fs::read_dir(path)?;
afc_client
.mk_dir(afc_path)
.await
.map_err(Error::IdeviceError)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let new_afc_path = format!(
"{}/{}",
afc_path,
path.file_name().unwrap().to_string_lossy()
);
afc_upload_dir(afc_client, &path, &new_afc_path).await?;
} else {
let mut file_handle = afc_client
.open(
format!(
"{}/{}",
afc_path,
path.file_name().unwrap().to_string_lossy()
),
idevice::afc::opcode::AfcFopenMode::WrOnly,
)
.await
.map_err(Error::IdeviceError)?;
let bytes = std::fs::read(&path)?;
file_handle
.write_entire(&bytes)
.await
.map_err(Error::IdeviceError)?;
file_handle.close().await.map_err(Error::IdeviceError)?;
}
}
Ok(())
})
}

View File

@@ -0,0 +1,9 @@
pub mod application;
pub mod builder;
pub mod bundle;
pub mod cert_identity;
#[cfg(feature = "install")]
pub mod install;
pub mod sideloader;
pub mod sign;
pub use builder::{SideloaderBuilder, TeamSelection};

View File

@@ -0,0 +1,247 @@
use crate::{
dev::{
app_groups::AppGroupsApi,
app_ids::AppIdsApi,
developer_session::DeveloperSession,
devices::DevicesApi,
teams::{DeveloperTeam, TeamsApi},
},
sideload::{
TeamSelection,
application::{Application, SpecialApp},
builder::MaxCertsBehavior,
cert_identity::CertificateIdentity,
sign,
},
util::{device::IdeviceInfo, storage::SideloadingStorage},
};
use std::path::PathBuf;
use idevice::provider::IdeviceProvider;
use rootcause::prelude::*;
use tracing::info;
pub struct Sideloader {
team_selection: TeamSelection,
storage: Box<dyn SideloadingStorage>,
dev_session: DeveloperSession,
machine_name: String,
apple_email: String,
max_certs_behavior: MaxCertsBehavior,
//extensions_behavior: ExtensionsBehavior,
delete_app_after_install: bool,
}
impl Sideloader {
/// Construct a new `Sideloader` instance with the provided configuration
///
/// See [`crate::sideload::SideloaderBuilder`] for more details and a more convenient way to construct a `Sideloader`.
pub fn new(
dev_session: DeveloperSession,
apple_email: String,
team_selection: TeamSelection,
max_certs_behavior: MaxCertsBehavior,
machine_name: String,
storage: Box<dyn SideloadingStorage>,
//extensions_behavior: ExtensionsBehavior,
delete_app_after_install: bool,
) -> Self {
Sideloader {
team_selection,
storage,
dev_session,
machine_name,
apple_email,
max_certs_behavior,
//extensions_behavior,
delete_app_after_install,
}
}
/// Sign the app at the provided path and return the path to the signed app bundle (in a temp dir). To sign and install, see [`Self::install_app`].
pub async fn sign_app(
&mut self,
app_path: PathBuf,
team: Option<DeveloperTeam>,
// this will be replaced with proper entitlement handling later
increased_memory_limit: bool,
) -> Result<(PathBuf, Option<SpecialApp>), Report> {
let team = match team {
Some(t) => t,
None => self.get_team().await?,
};
let cert_identity = CertificateIdentity::retrieve(
&self.machine_name,
&self.apple_email,
&mut self.dev_session,
&team,
self.storage.as_ref(),
&self.max_certs_behavior,
)
.await
.context("Failed to retrieve certificate identity")?;
let mut app = Application::new(app_path)?;
let special = app.get_special_app();
let main_bundle_id = app.main_bundle_id()?;
let main_app_name = app.main_app_name()?;
let main_app_id_str = format!("{}.{}", main_bundle_id, team.team_id);
app.update_bundle_id(&main_bundle_id, &main_app_id_str)?;
let mut app_ids = app
.register_app_ids(
/*&self.extensions_behavior, */ &mut self.dev_session,
&team,
)
.await?;
let main_app_id = match app_ids
.iter()
.find(|app_id| app_id.identifier == main_app_id_str)
{
Some(id) => id,
None => {
bail!(
"Main app ID {} not found in registered app IDs",
main_app_id_str
);
}
}
.clone();
let group_identifier = format!(
"group.{}",
if Some(SpecialApp::SideStoreLc) == special {
format!("com.SideStore.SideStore.{}", team.team_id)
} else {
main_app_id_str.clone()
}
);
let app_group = self
.dev_session
.ensure_app_group(&team, &main_app_name, &group_identifier, None)
.await?;
for app_id in app_ids.iter_mut() {
app_id
.ensure_group_feature(&mut self.dev_session, &team)
.await?;
self.dev_session
.assign_app_group(&team, &app_group, app_id, None)
.await?;
if increased_memory_limit {
self.dev_session
.add_increased_memory_limit(&team, app_id)
.await?;
}
}
info!("App IDs configured");
app.apply_special_app_behavior(&special, &group_identifier, &cert_identity)
.await
.context("Failed to modify app bundle")?;
let provisioning_profile = self
.dev_session
.download_team_provisioning_profile(&team, &main_app_id, None)
.await?;
info!("Acquired provisioning profile");
app.bundle.write_info()?;
for ext in app.bundle.app_extensions_mut() {
ext.write_info()?;
}
for ext in app.bundle.frameworks_mut() {
ext.write_info()?;
}
tokio::fs::write(
app.bundle.bundle_dir.join("embedded.mobileprovision"),
provisioning_profile.encoded_profile.as_ref(),
)
.await?;
sign::sign(
&mut app,
&cert_identity,
&provisioning_profile,
&special,
&team,
)
.context("Failed to sign app")?;
info!("App signed!");
Ok((app.bundle.bundle_dir.clone(), special))
}
#[cfg(feature = "install")]
/// Sign and install an app to a device.
pub async fn install_app(
&mut self,
device_provider: &impl IdeviceProvider,
app_path: PathBuf,
// this is gross but will be replaced with proper entitlement handling later
increased_memory_limit: bool,
) -> Result<Option<SpecialApp>, Report> {
let device_info = IdeviceInfo::from_device(device_provider).await?;
let team = self.get_team().await?;
self.dev_session
.ensure_device_registered(&team, &device_info.name, &device_info.udid, None)
.await?;
let (signed_app_path, special_app) = self
.sign_app(app_path, Some(team), increased_memory_limit)
.await?;
info!("Transferring App...");
crate::sideload::install::install_app(device_provider, &signed_app_path, |progress| {
info!("Installing: {}%", progress);
})
.await
.context("Failed to install app on device")?;
if self.delete_app_after_install {
if let Err(e) = tokio::fs::remove_dir_all(signed_app_path).await {
tracing::warn!("Failed to remove temporary signed app file: {}", e);
};
}
Ok(special_app)
}
/// Get the developer team according to the configured team selection behavior
pub async fn get_team(&mut self) -> Result<DeveloperTeam, Report> {
let teams = self.dev_session.list_teams().await?;
Ok(match teams.len() {
0 => {
bail!("No developer teams available")
}
1 => teams.into_iter().next().unwrap(),
_ => {
info!(
"Multiple developer teams found, {} as per configuration",
self.team_selection
);
match &self.team_selection {
TeamSelection::First => teams.into_iter().next().unwrap(),
TeamSelection::Prompt(prompt_fn) => {
let selection =
prompt_fn(&teams).ok_or_else(|| report!("No team selected"))?;
teams
.into_iter()
.find(|t| t.team_id == selection)
.ok_or_else(|| report!("No team found with ID {}", selection))?
}
}
}
})
}
}

View File

@@ -0,0 +1,114 @@
use apple_codesign::{SigningSettings, UnifiedSigner};
use plist::Dictionary;
use plist_macro::plist_to_xml_string;
use rootcause::{option_ext::OptionExt, prelude::*};
use tracing::info;
use crate::{
dev::{app_ids::Profile, teams::DeveloperTeam},
sideload::{
application::{Application, SpecialApp},
cert_identity::CertificateIdentity,
},
util::plist::PlistDataExtract,
};
pub fn sign(
app: &mut Application,
cert_identity: &CertificateIdentity,
provisioning_profile: &Profile,
special: &Option<SpecialApp>,
team: &DeveloperTeam,
) -> Result<(), Report> {
let mut settings = signing_settings(cert_identity)?;
let entitlements: Dictionary = entitlements_from_prov(
provisioning_profile.encoded_profile.as_ref(),
special,
team,
)?;
settings
.set_entitlements_xml(
apple_codesign::SettingsScope::Main,
plist_to_xml_string(&entitlements),
)
.context("Failed to set entitlements XML")?;
let signer = UnifiedSigner::new(settings);
for bundle in app.bundle.collect_bundles_sorted() {
info!(
"Signing {}",
bundle
.bundle_dir
.file_name()
.unwrap_or(bundle.bundle_dir.as_os_str())
.to_string_lossy()
);
signer
.sign_path_in_place(&bundle.bundle_dir)
.context(format!(
"Failed to sign bundle: {}",
bundle.bundle_dir.display()
))?;
}
Ok(())
}
pub fn signing_settings<'a>(cert: &'a CertificateIdentity) -> Result<SigningSettings<'a>, Report> {
let mut settings = SigningSettings::default();
cert.setup_signing_settings(&mut settings)?;
settings.set_for_notarization(false);
settings.set_shallow(true);
Ok(settings)
}
fn entitlements_from_prov(
data: &[u8],
special: &Option<SpecialApp>,
team: &DeveloperTeam,
) -> Result<Dictionary, Report> {
let start = data
.windows(6)
.position(|w| w == b"<plist")
.ok_or_report()?;
let end = data
.windows(8)
.rposition(|w| w == b"</plist>")
.ok_or_report()?
+ 8;
let plist_data = &data[start..end];
let plist = plist::Value::from_reader_xml(plist_data)?;
let mut entitlements = plist
.as_dictionary()
.ok_or_report()?
.get_dict("Entitlements")?
.clone();
if matches!(
special,
Some(SpecialApp::SideStoreLc) | Some(SpecialApp::LiveContainer)
) {
let mut keychain_access = vec![plist::Value::String(format!(
"{}.com.kdt.livecontainer.shared",
team.team_id
))];
for number in 1..128 {
keychain_access.push(plist::Value::String(format!(
"{}.com.kdt.livecontainer.shared.{}",
team.team_id, number
)));
}
entitlements.insert(
"keychain-access-groups".to_string(),
plist::Value::Array(keychain_access),
);
}
Ok(entitlements)
}

View File

@@ -0,0 +1,44 @@
use idevice::{IdeviceService, lockdown::LockdownClient, provider::IdeviceProvider};
use rootcause::prelude::*;
pub struct IdeviceInfo {
pub name: String,
pub udid: String,
}
impl IdeviceInfo {
pub fn new(name: String, udid: String) -> Self {
Self { name, udid }
}
pub async fn from_device(device: &impl IdeviceProvider) -> Result<Self, Report> {
let mut lockdown = LockdownClient::connect(device)
.await
.context("Failed to connect to device lockdown")?;
let pairing = device
.get_pairing_file()
.await
.context("Failed to get device pairing file")?;
lockdown
.start_session(&pairing)
.await
.context("Failed to start lockdown session")?;
let device_name = lockdown
.get_value(Some("DeviceName"), None)
.await
.context("Failed to get device name")?
.as_string()
.ok_or_else(|| report!("Device name is not a string"))?
.to_string();
let device_udid = lockdown
.get_value(Some("UniqueDeviceID"), None)
.await
.context("Failed to get device UDID")?
.as_string()
.ok_or_else(|| report!("Device UDID is not a string"))?
.to_string();
Ok(Self::new(device_name, device_udid))
}
}

View File

@@ -0,0 +1,53 @@
use std::path::{Path, PathBuf};
use rootcause::prelude::*;
use crate::util::storage::SideloadingStorage;
pub struct FsStorage {
path: PathBuf,
}
impl FsStorage {
pub fn new(path: PathBuf) -> Self {
FsStorage { path }
}
}
impl Default for FsStorage {
fn default() -> Self {
Self::new(PathBuf::from("."))
}
}
impl SideloadingStorage for FsStorage {
fn store_data(&self, key: &str, data: &[u8]) -> Result<(), Report> {
let path = self.path.join(key);
let parent = path.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(parent).context("Failed to create storage directory")?;
std::fs::write(&path, data).context("Failed to write data to file")?;
Ok(())
}
fn retrieve_data(&self, key: &str) -> Result<Option<Vec<u8>>, Report> {
let path = self.path.join(key);
match std::fs::read(&path) {
Ok(data) => Ok(Some(data)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(report!(e).context("Failed to read data from file").into()),
}
}
fn store(&self, key: &str, value: &str) -> Result<(), Report> {
self.store_data(key, value.as_bytes())
}
fn retrieve(&self, key: &str) -> Result<Option<String>, Report> {
match self.retrieve_data(key) {
Ok(Some(data)) => Ok(Some(String::from_utf8_lossy(&data).into_owned())),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
}

View File

@@ -0,0 +1,64 @@
use crate::util::storage::SideloadingStorage;
use keyring::Entry;
use rootcause::prelude::*;
pub struct KeyringStorage {
pub service_name: String,
}
impl KeyringStorage {
pub fn new(service_name: String) -> Self {
KeyringStorage { service_name }
}
}
impl Default for KeyringStorage {
fn default() -> Self {
KeyringStorage {
service_name: "isideload".to_string(),
}
}
}
impl SideloadingStorage for KeyringStorage {
fn store(&self, key: &str, value: &str) -> Result<(), Report> {
Entry::new(&self.service_name, key)?.set_password(value)?;
Ok(())
}
fn retrieve(&self, key: &str) -> Result<Option<String>, Report> {
let entry = Entry::new(&self.service_name, key)?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn delete(&self, key: &str) -> Result<(), Report> {
let entry = Entry::new(&self.service_name, key)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
// Linux doesn't seem to properly retrive binary secrets, so we don't use this implementation and instead let it fall back to base64 encoding.
// Windows fails to store the base64 encoded data because it is too long.
#[cfg(target_os = "windows")]
fn store_data(&self, key: &str, value: &[u8]) -> Result<(), Report> {
Entry::new(&self.service_name, key)?.set_secret(value)?;
Ok(())
}
#[cfg(target_os = "windows")]
fn retrieve_data(&self, key: &str) -> Result<Option<Vec<u8>>, Report> {
let entry = Entry::new(&self.service_name, key)?;
match entry.get_secret() {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
}

View File

@@ -0,0 +1,7 @@
pub mod device;
#[cfg(feature = "fs-storage")]
pub mod fs_storage;
#[cfg(feature = "keyring-storage")]
pub mod keyring_storage;
pub mod plist;
pub mod storage;

131
isideload/src/util/plist.rs Normal file
View File

@@ -0,0 +1,131 @@
use plist::Dictionary;
use plist_macro::pretty_print_dictionary;
use rootcause::prelude::*;
use serde::de::DeserializeOwned;
use tracing::error;
pub struct SensitivePlistAttachment {
pub plist: Dictionary,
}
impl SensitivePlistAttachment {
pub fn new(plist: Dictionary) -> Self {
SensitivePlistAttachment { plist }
}
pub fn from_text(text: &str) -> Self {
let dict: Result<Dictionary, _> = plist::from_bytes(text.as_bytes());
if let Err(e) = &dict {
error!(
"Failed to parse plist text for sensitive attachment, returning empty plist: {:?}",
e
);
return SensitivePlistAttachment::new(Dictionary::new());
}
SensitivePlistAttachment::new(dict.unwrap())
}
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// if env variable DEBUG_SENSITIVE is set, print full plist
if std::env::var("DEBUG_SENSITIVE").is_ok() {
return writeln!(f, "{}", pretty_print_dictionary(&self.plist));
}
writeln!(
f,
"<Potentially sensitive data - set DEBUG_SENSITIVE env variable to see contents>"
)
}
}
impl std::fmt::Display for SensitivePlistAttachment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt(f)
}
}
impl std::fmt::Debug for SensitivePlistAttachment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt(f)
}
}
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<&Dictionary, Report>;
fn get_bool(&self, key: &str) -> Result<bool, Report>;
fn get_struct<T: DeserializeOwned>(&self, key: &str) -> Result<T, Report>;
}
impl PlistDataExtract for 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(SensitivePlistAttachment::new(self.clone()))
})
}
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(SensitivePlistAttachment::new(self.clone()))
})
}
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(SensitivePlistAttachment::new(self.clone()))
})
}
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(SensitivePlistAttachment::new(self.clone()))
})
}
fn get_dict(&self, key: &str) -> Result<&Dictionary, Report> {
self.get(key)
.and_then(|v| v.as_dictionary())
.ok_or_else(|| {
report!("Plist missing dictionary for key '{}'", key)
.attach(SensitivePlistAttachment::new(self.clone()))
})
}
fn get_struct<T: DeserializeOwned>(&self, key: &str) -> Result<T, Report> {
let dict = self.get(key);
if dict.is_none() {
return Err(report!("Plist missing dictionary for key '{}'", key)
.attach(SensitivePlistAttachment::new(self.clone())));
}
let dict = dict.unwrap();
let struct_data: T = plist::from_value(dict).map_err(|e| {
report!(
"Failed to deserialize plist struct for key '{}': {:?}",
key,
e
)
.attach(SensitivePlistAttachment::new(
dict.as_dictionary().cloned().unwrap_or_default(),
))
})?;
Ok(struct_data)
}
fn get_bool(&self, key: &str) -> Result<bool, Report> {
self.get(key).and_then(|v| v.as_boolean()).ok_or_else(|| {
report!("Plist missing boolean for key '{}'", key)
.attach(SensitivePlistAttachment::new(self.clone()))
})
}
}

View File

@@ -0,0 +1,82 @@
use std::{collections::HashMap, sync::Mutex};
use base64::prelude::*;
use rootcause::prelude::*;
/// A trait for storing and retrieving sideloading related data, such as anisette state and certificates.
pub trait SideloadingStorage: Send + Sync {
fn store(&self, key: &str, value: &str) -> Result<(), Report>;
fn retrieve(&self, key: &str) -> Result<Option<String>, Report>;
fn store_data(&self, key: &str, value: &[u8]) -> Result<(), Report> {
self.store(key, &BASE64_STANDARD.encode(value))
}
fn retrieve_data(&self, key: &str) -> Result<Option<Vec<u8>>, Report> {
if let Some(value) = self.retrieve(key)? {
Ok(Some(BASE64_STANDARD.decode(value)?))
} else {
Ok(None)
}
}
fn delete(&self, key: &str) -> Result<(), Report> {
self.store(key, "")
}
}
/// Factory function to create a new storage instance based on enabled features. The priority is `keyring-storage`, then `fs-storage`, and finally an in-memory storage if neither of those features are enabled.
pub fn new_storage() -> impl SideloadingStorage {
#[cfg(feature = "keyring-storage")]
{
return crate::util::keyring_storage::KeyringStorage::default();
}
#[cfg(feature = "fs-storage")]
{
return crate::util::fs_storage::FsStorage::default();
}
#[cfg(not(any(feature = "keyring-storage", feature = "fs-storage")))]
{
tracing::warn!(
"Keyring storage not enabled, falling back to in-memory storage. This means that the anisette state and certificates will not be saved across runs. Enable the 'keyring-storage' or 'fs-storage' feature for persistance."
);
return InMemoryStorage::new();
}
}
pub struct InMemoryStorage {
storage: Mutex<HashMap<String, String>>,
}
impl Default for InMemoryStorage {
fn default() -> Self {
Self::new()
}
}
impl InMemoryStorage {
pub fn new() -> Self {
InMemoryStorage {
storage: Mutex::new(HashMap::new()),
}
}
}
impl SideloadingStorage for InMemoryStorage {
fn store(&self, key: &str, value: &str) -> Result<(), Report> {
let mut storage = self.storage.lock().unwrap();
storage.insert(key.to_string(), value.to_string());
Ok(())
}
fn retrieve(&self, key: &str) -> Result<Option<String>, Report> {
let storage = self.storage.lock().unwrap();
Ok(storage.get(key).cloned())
}
fn delete(&self, key: &str) -> Result<(), Report> {
let mut storage = self.storage.lock().unwrap();
storage.remove(key);
Ok(())
}
}