mirror of
https://github.com/nab138/isideload.git
synced 2026-03-02 06:26:16 +01:00
Update to isideload-next
This commit is contained in:
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -15,8 +15,15 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: "ubuntu-22.04"
|
||||
- platform: "ubuntu-latest"
|
||||
artifact_name: "minimal"
|
||||
asset_name: "minimal-linux"
|
||||
- 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 }}
|
||||
steps:
|
||||
@@ -29,16 +36,24 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-tauri-${{ hashFiles('Cargo.lock') }}
|
||||
key: ${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-tauri-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add MSVC to PATH
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libdbus-1-dev
|
||||
|
||||
- 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
5
.gitignore
vendored
@@ -1 +1,6 @@
|
||||
target
|
||||
|
||||
state.plist
|
||||
|
||||
*.mobileprovision
|
||||
*.pem
|
||||
3544
Cargo.lock
generated
3544
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["isideload", "examples/minimal"]
|
||||
members = ["examples/minimal","isideload"]
|
||||
default-members = ["isideload"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
107
README.md
@@ -2,110 +2,31 @@
|
||||
|
||||
[](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).
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
```
|
||||
A full example is available is in [examples/minimal](examples/minimal/).
|
||||
|
||||
Then, you can use it like so:
|
||||
## TODO
|
||||
|
||||
```rs
|
||||
use std::{env, path::PathBuf, sync::Arc};
|
||||
Things left todo before the rewrite is considered finished
|
||||
|
||||
use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection};
|
||||
use isideload::{
|
||||
AnisetteConfiguration, AppleAccount, SideloadConfiguration,
|
||||
developer_session::DeveloperSession, sideload::sideload_app,
|
||||
};
|
||||
|
||||
#[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).
|
||||
- Proper entitlement handling
|
||||
- actually parse macho files and stuff, right now it just uses the bare minimum and applies extra entitlements for livecontainer
|
||||
- Reduce duplicate dependencies
|
||||
- partially just need to wait for the rust crypto ecosystem to get through another release cycle
|
||||
- More parallelism and caching for better performance
|
||||
|
||||
## 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
|
||||
|
||||
- The amazing [idevice](https://github.com/jkcoxson/idevice) crate is used to communicate with the device
|
||||
|
||||
- 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.
|
||||
|
||||
- [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
|
||||
- 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)
|
||||
- [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
|
||||
|
||||
6
examples/minimal/.gitignore
vendored
6
examples/minimal/.gitignore
vendored
@@ -1,5 +1 @@
|
||||
.zsign_cache
|
||||
keys
|
||||
*.ipa
|
||||
state.plist
|
||||
*.mobileprovision
|
||||
/target
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
name = "minimal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
isideload = { path = "../../isideload", features = ["vendored-openssl"] }
|
||||
idevice = { version = "0.1.46", features = ["usbmuxd", "ring"], default-features = false}
|
||||
tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] }
|
||||
isideload = { path = "../../isideload" }
|
||||
plist = "1.8.0"
|
||||
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"] }
|
||||
5
examples/minimal/README.md
Normal file
5
examples/minimal/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# minimal
|
||||
|
||||
A minimal sideloading CLI to to demonstrate isideload.
|
||||
|
||||
Usage: `minimal <appleid@icloud.com> <password> <app>`
|
||||
@@ -1,24 +1,56 @@
|
||||
use std::{env, path::PathBuf, sync::Arc};
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection};
|
||||
use isideload::{
|
||||
AnisetteConfiguration, AppleAccount, SideloadConfiguration,
|
||||
developer_session::DeveloperSession, sideload::sideload_app,
|
||||
anisette::remote_v3::RemoteV3AnisetteProvider,
|
||||
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]
|
||||
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 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(
|
||||
args.get(1)
|
||||
args.get(3)
|
||||
.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;
|
||||
if usbmuxd.is_err() {
|
||||
panic!("Failed to connect to usbmuxd: {:?}", usbmuxd.err());
|
||||
@@ -31,36 +63,68 @@ async fn main() {
|
||||
}
|
||||
|
||||
let provider = devs
|
||||
.iter()
|
||||
.next()
|
||||
.first()
|
||||
.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 team_selection_prompt = |teams: &Vec<DeveloperTeam>| {
|
||||
println!("Please select a team:");
|
||||
for (index, team) in teams.iter().enumerate() {
|
||||
println!(
|
||||
"{}: {} ({})",
|
||||
index + 1,
|
||||
team.name.as_deref().unwrap_or("<Unnamed>"),
|
||||
team.team_id
|
||||
);
|
||||
}
|
||||
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(
|
||||
|| Ok((apple_id.to_string(), apple_password.to_string())),
|
||||
get_2fa_code,
|
||||
anisette_config,
|
||||
let cert_selection_prompt = |certs: &Vec<DevelopmentCertificate>| {
|
||||
println!("Maximum number of certificates reached. Please select certificates to revoke:");
|
||||
for (index, cert) in certs.iter().enumerate() {
|
||||
println!(
|
||||
"({}) {}: {}",
|
||||
index + 1,
|
||||
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<_>>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
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 config = SideloadConfiguration::default().set_machine_name("isideload-demo".to_string());
|
||||
|
||||
sideload_app(&provider, &dev_session, app_path, config)
|
||||
.await
|
||||
.unwrap()
|
||||
let result = sideloader.install_app(&provider, app_path, true).await;
|
||||
match result {
|
||||
Ok(_) => println!("App installed successfully"),
|
||||
Err(e) => panic!("Failed to install app: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "isideload"
|
||||
description = "Sideload iOS/iPadOS applications"
|
||||
license = "MPL-2.0"
|
||||
license = "MIT"
|
||||
authors = ["Nicholas Sharp <nab@nabdev.me>"]
|
||||
version = "0.1.25"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
repository = "https://github.com/nab138/isideload"
|
||||
documentation = "https://docs.rs/isideload"
|
||||
@@ -11,21 +11,49 @@ keywords = ["ios", "sideload"]
|
||||
readme = "../README.md"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
vendored-openssl = ["openssl/vendored", "zsign-rust/vendored-openssl"]
|
||||
obfuscate = ["idevice/obfuscate", "icloud_auth/obfuscate", "dep:obfstr"]
|
||||
default = ["install", "keyring-storage"]
|
||||
install = ["dep:idevice"]
|
||||
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]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
plist = { version = "1.7" }
|
||||
icloud_auth = { version = "0.1.10", package = "nab138_icloud_auth" }
|
||||
uuid = { version = "1.17.0", features = ["v4"] }
|
||||
zip = { version = "4.3", default-features = false, features = ["deflate"] }
|
||||
hex = "0.4"
|
||||
sha1 = "0.10"
|
||||
idevice = { version = "0.1.51", features = ["afc", "installation_proxy", "ring"], default-features = false }
|
||||
openssl = "0.10"
|
||||
zsign-rust = "0.1.7"
|
||||
thiserror = "2"
|
||||
obfstr = { version = "0.4", optional = true }
|
||||
reqwest = { version = "0.11.14", features = ["blocking", "json", "default-tls"] }
|
||||
idevice = { version = "0.1.52", optional = true, features = ["afc", "installation_proxy"]}
|
||||
plist = "1.8"
|
||||
plist-macro = "0.1.4"
|
||||
reqwest = { version = "0.13.2", features = ["json", "gzip"] }
|
||||
thiserror = "2.0.17"
|
||||
async-trait = "0.1.89"
|
||||
serde = "1.0.228"
|
||||
rand = "0.10.0"
|
||||
uuid = {version = "1.20.0", features = ["v4"] }
|
||||
tracing = "0.1.44"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
|
||||
rootcause = "0.12.0"
|
||||
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"] }
|
||||
158
isideload/src/anisette/mod.rs
Normal file
158
isideload/src/anisette/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
391
isideload/src/anisette/remote_v3/mod.rs
Normal file
391
isideload/src/anisette/remote_v3/mod.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
81
isideload/src/anisette/remote_v3/state.rs
Normal file
81
isideload/src/anisette/remote_v3/state.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
683
isideload/src/auth/apple_account.rs
Normal file
683
isideload/src/auth/apple_account.rs
Normal 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,
|
||||
}
|
||||
BIN
isideload/src/auth/apple_root.der
Normal file
BIN
isideload/src/auth/apple_root.der
Normal file
Binary file not shown.
81
isideload/src/auth/builder.rs
Normal file
81
isideload/src/auth/builder.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
209
isideload/src/auth/grandslam.rs
Normal file
209
isideload/src/auth/grandslam.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
3
isideload/src/auth/mod.rs
Normal file
3
isideload/src/auth/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod apple_account;
|
||||
pub mod builder;
|
||||
pub mod grandslam;
|
||||
123
isideload/src/dev/app_groups.rs
Normal file
123
isideload/src/dev/app_groups.rs
Normal 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
|
||||
}
|
||||
}
|
||||
239
isideload/src/dev/app_ids.rs
Normal file
239
isideload/src/dev/app_ids.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
181
isideload/src/dev/certificates.rs
Normal file
181
isideload/src/dev/certificates.rs
Normal 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
|
||||
}
|
||||
}
|
||||
183
isideload/src/dev/developer_session.rs
Normal file
183
isideload/src/dev/developer_session.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
29
isideload/src/dev/device_type.rs
Normal file
29
isideload/src/dev/device_type.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
90
isideload/src/dev/devices.rs
Normal file
90
isideload/src/dev/devices.rs
Normal 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
7
isideload/src/dev/mod.rs
Normal 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;
|
||||
36
isideload/src/dev/teams.rs
Normal file
36
isideload/src/dev/teams.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 thiserror::Error as ThisError;
|
||||
use zsign_rust::ZSignError;
|
||||
use rootcause::{
|
||||
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}")]
|
||||
InvalidBundle(String),
|
||||
#[error("Certificate error: {0}")]
|
||||
Certificate(String),
|
||||
#[error(transparent)]
|
||||
Filesystem(#[from] IOError),
|
||||
|
||||
#[error(transparent)]
|
||||
IdeviceError(#[from] IdeviceError),
|
||||
#[error(transparent)]
|
||||
ZSignError(#[from] ZSignError),
|
||||
#[error(transparent)]
|
||||
ICloudError(#[from] icloud_auth::Error),
|
||||
}
|
||||
|
||||
pub trait SideloadLogger: Send + Sync {
|
||||
fn log(&self, message: &str);
|
||||
fn error(&self, error: &Error);
|
||||
}
|
||||
// The default reqwest error formatter sucks and provides no info
|
||||
struct ReqwestErrorFormatter;
|
||||
|
||||
pub struct DefaultLogger;
|
||||
|
||||
impl SideloadLogger for DefaultLogger {
|
||||
fn log(&self, message: &str) {
|
||||
println!("{message}");
|
||||
impl ContextFormatterHook<reqwest::Error> for ReqwestErrorFormatter {
|
||||
fn display(
|
||||
&self,
|
||||
report: rootcause::ReportRef<'_, reqwest::Error, markers::Uncloneable, markers::Local>,
|
||||
f: &mut std::fmt::Formatter<'_>,
|
||||
) -> std::fmt::Result {
|
||||
writeln!(f, "{}", report.format_current_context_unhooked())?;
|
||||
let mut source = report.current_context_error_source();
|
||||
while let Some(s) = source {
|
||||
writeln!(f, "Caused by: {:?}", s)?;
|
||||
source = s.source();
|
||||
}
|
||||
|
||||
fn error(&self, error: &Error) {
|
||||
eprintln!("Error: {}", error);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
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")]
|
||||
#[macro_export]
|
||||
macro_rules! obf {
|
||||
($lit:literal) => {
|
||||
&obfstr::obfstring!($lit)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "obfuscate"))]
|
||||
#[macro_export]
|
||||
macro_rules! obf {
|
||||
($lit:literal) => {
|
||||
&$lit.to_string()
|
||||
};
|
||||
pub fn init() -> Result<(), Report> {
|
||||
Hooks::new()
|
||||
.context_formatter::<reqwest::Error, _>(ReqwestErrorFormatter)
|
||||
.install()
|
||||
.context("Failed to install error reporting hooks")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
288
isideload/src/sideload/application.rs
Normal file
288
isideload/src/sideload/application.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
160
isideload/src/sideload/builder.rs
Normal file
160
isideload/src/sideload/builder.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
234
isideload/src/sideload/bundle.rs
Normal file
234
isideload/src/sideload/bundle.rs
Normal 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)
|
||||
}
|
||||
340
isideload/src/sideload/cert_identity.rs
Normal file
340
isideload/src/sideload/cert_identity.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
95
isideload/src/sideload/install.rs
Normal file
95
isideload/src/sideload/install.rs
Normal 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(())
|
||||
})
|
||||
}
|
||||
9
isideload/src/sideload/mod.rs
Normal file
9
isideload/src/sideload/mod.rs
Normal 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};
|
||||
247
isideload/src/sideload/sideloader.rs
Normal file
247
isideload/src/sideload/sideloader.rs
Normal 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))?
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
114
isideload/src/sideload/sign.rs
Normal file
114
isideload/src/sideload/sign.rs
Normal 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)
|
||||
}
|
||||
44
isideload/src/util/device.rs
Normal file
44
isideload/src/util/device.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
53
isideload/src/util/fs_storage.rs
Normal file
53
isideload/src/util/fs_storage.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
64
isideload/src/util/keyring_storage.rs
Normal file
64
isideload/src/util/keyring_storage.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
7
isideload/src/util/mod.rs
Normal file
7
isideload/src/util/mod.rs
Normal 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
131
isideload/src/util/plist.rs
Normal 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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
82
isideload/src/util/storage.rs
Normal file
82
isideload/src/util/storage.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user