diff --git a/Cargo.lock b/Cargo.lock index 4bacf27..cb09758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "x509-certificate 0.24.0", + "x509-certificate", ] [[package]] @@ -246,7 +246,7 @@ dependencies = [ "widestring", "windows-sys 0.59.0", "x509", - "x509-certificate 0.24.0", + "x509-certificate", "xml-rs", "yasna", "zeroize", @@ -294,7 +294,7 @@ dependencies = [ "signature 2.2.0", "thiserror 2.0.17", "url", - "x509-certificate 0.24.0", + "x509-certificate", "xml-rs", "xz2", ] @@ -1390,7 +1390,7 @@ dependencies = [ "reqwest 0.12.24", "ring", "signature 2.2.0", - "x509-certificate 0.24.0", + "x509-certificate", ] [[package]] @@ -2504,12 +2504,10 @@ name = "isideload" version = "0.1.21" dependencies = [ "apple-codesign", - "der 0.7.10", "hex", "idevice", "nab138_icloud_auth", "p12", - "pkcs8 0.10.2", "plist", "rand 0.8.5", "rcgen", @@ -2517,8 +2515,9 @@ dependencies = [ "serde", "sha1", "thiserror 2.0.17", + "tokio", "uuid", - "x509-certificate 0.25.0", + "x509-certificate", "zip 4.6.1", ] @@ -5437,25 +5436,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "x509-certificate" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca9eb9a0c822c67129d5b8fcc2806c6bc4f50496b420825069a440669bcfbf7f" -dependencies = [ - "bcder", - "bytes", - "chrono", - "der 0.7.10", - "hex", - "pem", - "ring", - "signature 2.2.0", - "spki 0.7.3", - "thiserror 2.0.17", - "zeroize", -] - [[package]] name = "xml-rs" version = "0.8.28" diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index aa49688..9b2e9a9 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -58,7 +58,9 @@ async fn main() { 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()); + let config = SideloadConfiguration::default() + .set_machine_name("isideload-demo".to_string()) + .set_force_sidestore(true); sideload_app(&provider, &dev_session, app_path, config) .await diff --git a/isideload/Cargo.toml b/isideload/Cargo.toml index b9581d3..1675b36 100644 --- a/isideload/Cargo.toml +++ b/isideload/Cargo.toml @@ -24,10 +24,9 @@ sha1 = "0.10" idevice = { version = "0.1.46", features = ["afc", "installation_proxy", "ring"], default-features = false } thiserror = "2" apple-codesign = "0.29.0" -x509-certificate = "0.25.0" +x509-certificate = "0.24.0" rsa = "0.9" -pkcs8 = "0.10" rcgen = "0.13" -p12 = "0.6" -der = "0.7" rand = "0.8" +tokio = "1.48.0" +p12 = "0.6.3" diff --git a/isideload/src/bundle.rs b/isideload/src/bundle.rs index 117acfd..c4fc36c 100644 --- a/isideload/src/bundle.rs +++ b/isideload/src/bundle.rs @@ -7,12 +7,13 @@ use std::{ path::{Path, PathBuf}, }; +#[derive(Debug, Clone)] pub struct Bundle { pub app_info: Dictionary, pub bundle_dir: PathBuf, - + pub bundle_type: BundleType, app_extensions: Vec, - _frameworks: Vec, + frameworks: Vec, _libraries: Vec, } @@ -79,9 +80,15 @@ impl Bundle { Ok(Bundle { app_info, + bundle_type: BundleType::from_extension( + bundle_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or(""), + ), bundle_dir: bundle_path, app_extensions, - _frameworks: frameworks, + frameworks, _libraries: libraries, }) } @@ -125,6 +132,15 @@ impl Bundle { } Ok(()) } + + pub fn embedded_bundles(&self) -> Vec<&Bundle> { + let mut bundles = Vec::new(); + bundles.extend(self.app_extensions.iter()); + bundles.extend(self.frameworks.iter()); + bundles.push(self); + bundles.sort_by_key(|b| b.bundle_dir.components().count()); + bundles + } } fn assert_bundle(condition: bool, msg: &str) -> Result<(), Error> { @@ -178,3 +194,23 @@ fn find_dylibs(dir: &Path, bundle_root: &Path) -> Result, Error> { collect_dylibs(dir, bundle_root, &mut libraries)?; Ok(libraries) } + +// Borrowed from https://github.com/khcrysalis/PlumeImpactor/blob/main/crates/utils/src/bundle.rs +#[derive(Debug, Clone)] +pub enum BundleType { + App, + AppExtension, + Framework, + Unknown, +} + +impl BundleType { + pub fn from_extension(ext: &str) -> Self { + match ext { + "app" => BundleType::App, + "appex" => BundleType::AppExtension, + "framework" => BundleType::Framework, + _ => BundleType::Unknown, + } + } +} diff --git a/isideload/src/certificate.rs b/isideload/src/certificate.rs index b1dd552..feb445b 100644 --- a/isideload/src/certificate.rs +++ b/isideload/src/certificate.rs @@ -1,8 +1,8 @@ // This file was made using https://github.com/Dadoum/Sideloader as a reference. +use apple_codesign::SigningSettings; use hex; use rcgen::{CertificateParams, DnType, KeyPair}; -use rsa::pkcs1::EncodeRsaPublicKey; use rsa::{ RsaPrivateKey, pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}, @@ -11,16 +11,16 @@ use sha1::{Digest, Sha1}; use std::{ fs, path::{Path, PathBuf}, - process::Command, }; -use x509_certificate::X509Certificate; +use x509_certificate::{CapturedX509Certificate, InMemorySigningKeyPair, Sign, X509Certificate}; use crate::Error; use crate::developer_session::{DeveloperDeviceType, DeveloperSession, DeveloperTeam}; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct CertificateIdentity { pub certificate: Option, + pub key_pair: InMemorySigningKeyPair, pub private_key: RsaPrivateKey, pub key_file: PathBuf, pub cert_file: PathBuf, @@ -65,8 +65,17 @@ impl CertificateIdentity { private_key }; + let key_pair = InMemorySigningKeyPair::from_pkcs8_der( + private_key + .to_pkcs8_der() + .map_err(|e| Error::Certificate(format!("Failed to encode private key: {}", e)))? + .as_bytes(), + ) + .map_err(|e| Error::Certificate(format!("Failed to decode private key: {}", e)))?; + let mut cert_identity = CertificateIdentity { certificate: None, + key_pair, private_key, key_file, cert_file, @@ -105,12 +114,7 @@ impl CertificateIdentity { .await .map_err(|e| Error::Certificate(format!("Failed to list certificates: {:?}", e)))?; - let our_public_key_der = self - .private_key - .to_public_key() - .to_pkcs1_der() - .map_err(|e| Error::Certificate(format!("Failed to get public key: {}", e)))? - .to_vec(); + let our_public_key_der = self.key_pair.public_key_data().to_vec(); for cert in certificates .iter() @@ -233,29 +237,65 @@ impl CertificateIdentity { let serial = &cert.tbs_certificate().serial_number; let hex_str = hex::encode(serial.as_slice()); - Ok(hex_str.trim_start_matches("0").to_string()) + Ok(hex_str.trim_start_matches("0").to_string().to_uppercase()) } pub fn to_pkcs12(&self, password: &str) -> Result, Error> { - let output = Command::new("openssl") - .arg("pkcs12") - .arg("-export") - .arg("-inkey") - .arg(&self.key_file) - .arg("-in") - .arg(&self.cert_file) - .arg("-passout") - .arg(format!("pass:{}", password)) - .output() - .map_err(|e| Error::Certificate(format!("Failed to execute openssl: {}", e)))?; + let cert = self + .certificate + .as_ref() + .ok_or(Error::Certificate("Certificate not found".to_string()))?; - if !output.status.success() { - return Err(Error::Certificate(format!( - "openssl failed: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } + let cert_der = cert + .encode_der() + .map_err(|e| Error::Certificate(format!("Failed to encode certificate: {}", e)))?; - Ok(output.stdout) + let key_der = self + .private_key + .to_pkcs8_der() + .map_err(|e| Error::Certificate(format!("Failed to encode private key: {}", e)))?; + + let pfx = p12::PFX::new( + &cert_der, + key_der.as_bytes(), + None, + password, + &self.machine_name, + ) + .ok_or(Error::Certificate("Failed to create PKCS#12".to_string()))?; + + Ok(pfx.to_der()) + } + + pub fn to_signing_settings(&self) -> Result, Error> { + let mut settings = SigningSettings::default(); + + let certificate = self + .certificate + .as_ref() + .ok_or(Error::Certificate("Certificate not found".to_string()))?; + + settings.set_signing_key( + &self.key_pair, + CapturedX509Certificate::from_der( + certificate.encode_der().map_err(|e| { + Error::Certificate(format!("Failed to encode certificate: {}", e)) + })?, + ) + .map_err(|e| { + Error::Certificate(format!("Failed to create captured certificate: {}", e)) + })?, + ); + settings.chain_apple_certificates(); + settings.set_for_notarization(false); + settings.set_shallow(true); + settings.set_team_id_from_signing_certificate().ok_or({ + Error::Certificate("Failed to set team ID from signing certificate".to_string()) + })?; + settings + .set_time_stamp_url("http://timestamp.apple.com/ts01") + .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; + + Ok(settings) } } diff --git a/isideload/src/developer_session.rs b/isideload/src/developer_session.rs index 732c885..3e70dd4 100644 --- a/isideload/src/developer_session.rs +++ b/isideload/src/developer_session.rs @@ -724,3 +724,56 @@ pub struct ProvisioningProfile { pub _name: String, pub encoded_profile: Vec, } + +impl ProvisioningProfile { + // TODO: I'm not sure if this is the proper way to parse this but it works so... + pub fn profile_plist(&self) -> Result { + let start_marker = b""; + + let start = self + .encoded_profile + .windows(start_marker.len()) + .position(|w| w == start_marker) + .ok_or_else(|| { + Error::Generic("Failed to find start of plist in provisioning profile".to_string()) + })?; + + let end = self + .encoded_profile + .windows(end_marker.len()) + .position(|w| w == end_marker) + .ok_or_else(|| { + Error::Generic("Failed to find end of plist in provisioning profile".to_string()) + })? + + end_marker.len(); + + plist::from_bytes::(&self.encoded_profile[start..end]).map_err(|e| { + Error::Generic(format!("Failed to parse provisioning profile plist: {}", e)) + }) + } + + pub fn entitlements_xml(&self) -> Result { + let profile_plist = self.profile_plist()?; + let entitlements = profile_plist.get("Entitlements").ok_or_else(|| { + Error::Generic("No Entitlements found in provisioning profile".to_string()) + })?; + let mut buf = vec![]; + entitlements.to_writer_xml(&mut buf).map_err(|e| { + Error::Generic(format!( + "Failed to convert entitlements to XML for codesigning: {}", + e + )) + })?; + let entitlements = std::str::from_utf8(&buf) + .map_err(|e| { + Error::Generic(format!( + "Failed to convert entitlements to UTF-8 for codesigning: {}", + e + )) + })? + .to_string(); + + Ok(entitlements) + } +} diff --git a/isideload/src/lib.rs b/isideload/src/lib.rs index 108df09..8b843f0 100644 --- a/isideload/src/lib.rs +++ b/isideload/src/lib.rs @@ -55,16 +55,12 @@ impl SideloadLogger for DefaultLogger { /// 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 force SideStore App Group (fixes LiveContainer+SideStore issues) - pub force_sidestore_app_group: bool, + pub force_sidestore: bool, + pub skip_register_extensions: bool, } impl Default for SideloadConfiguration<'_> { @@ -80,32 +76,48 @@ impl<'a> SideloadConfiguration<'a> { logger: &DefaultLogger, store_dir: std::env::current_dir().unwrap(), revoke_cert: false, - force_sidestore_app_group: false, + force_sidestore: false, + skip_register_extensions: true, } } + /// An arbitrary machine name to appear on the certificate (e.x. "CrossCode") pub fn set_machine_name(mut self, machine_name: String) -> Self { self.machine_name = machine_name; self } + /// Logger for reporting progress and errors pub fn set_logger(mut self, logger: &'a dyn SideloadLogger) -> Self { self.logger = logger; self } + /// Directory used to store intermediate artifacts (profiles, certs, etc.). This directory will not be cleared at the end. pub fn set_store_dir(mut self, store_dir: std::path::PathBuf) -> Self { self.store_dir = store_dir; self } + /// Whether or not to revoke the certificate immediately after installation + #[deprecated( + since = "0.1.0", + note = "Certificates will now be placed in SideStore automatically so there is no need to revoke" + )] pub fn set_revoke_cert(mut self, revoke_cert: bool) -> Self { self.revoke_cert = revoke_cert; self } - pub fn set_force_sidestore_app_group(mut self, force: bool) -> Self { - self.force_sidestore_app_group = force; + /// Whether or not to treat the app as SideStore (fixes LiveContainer+SideStore issues) + pub fn set_force_sidestore(mut self, force: bool) -> Self { + self.force_sidestore = force; + self + } + + /// Whether or not to skip registering app extensions (save app IDs, default true) + pub fn set_skip_register_extensions(mut self, skip: bool) -> Self { + self.skip_register_extensions = skip; self } } diff --git a/isideload/src/sideload.rs b/isideload/src/sideload.rs index 2a344e4..c150208 100644 --- a/isideload/src/sideload.rs +++ b/isideload/src/sideload.rs @@ -1,18 +1,19 @@ // This file was made using https://github.com/Dadoum/Sideloader as a reference. -use apple_codesign::{BundleSigner, SigningSettings}; -use der::Encode; +use apple_codesign::{SettingsScope, UnifiedSigner}; use idevice::IdeviceService; use idevice::lockdown::LockdownClient; use idevice::provider::IdeviceProvider; use crate::application::Application; +use crate::developer_session::ProvisioningProfile; use crate::device::install_app; use crate::{DeveloperTeam, Error, SideloadConfiguration, SideloadLogger}; use crate::{ certificate::CertificateIdentity, developer_session::{DeveloperDeviceType, DeveloperSession}, }; +use std::collections::HashMap; use std::{io::Write, path::PathBuf}; fn error_and_return(logger: &dyn SideloadLogger, error: Error) -> Result<(), Error> { @@ -106,7 +107,8 @@ pub async fn sideload_app( }; let mut app = Application::new(app_path)?; - let is_sidestore = app.bundle.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore"; + let is_sidestore = config.force_sidestore + || app.bundle.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore"; let main_app_bundle_id = match app.bundle.bundle_identifier() { Some(id) => id.to_string(), None => { @@ -255,7 +257,7 @@ pub async fn sideload_app( let group_identifier = format!( "group.{}", - if config.force_sidestore_app_group { + if config.force_sidestore { format!("com.SideStore.SideStore.{}", team.team_id) } else { main_app_id_str.clone() @@ -321,7 +323,7 @@ pub async fn sideload_app( matching_app_groups[0].clone() }; - //let mut provisioning_profiles: HashMap = HashMap::new(); + let mut provisioning_profiles: HashMap = HashMap::new(); for app_id in app_ids { let assign_res = dev_session .assign_application_group_to_app_id( @@ -334,37 +336,21 @@ pub async fn sideload_app( if assign_res.is_err() { return error_and_return(logger, assign_res.err().unwrap()); } - // let provisioning_profile = match account - // // This doesn't seem right to me, but it's what Sideloader does... Shouldn't it be downloading the provisioning profile for this app ID, not the main? - // .download_team_provisioning_profile(DeveloperDeviceType::Ios, &team, &main_app_id) - // .await - // { - // Ok(pp /* tee hee */) => pp, - // Err(e) => { - // return emit_error_and_return( - // &window, - // &format!("Failed to download provisioning profile: {:?}", e), - // ); - // } - // }; - // provisioning_profiles.insert(app_id.identifier.clone(), provisioning_profile); + let provisioning_profile = match dev_session + .download_team_provisioning_profile(DeveloperDeviceType::Ios, &team, &app_id) + .await + { + Ok(pp /* tee hee */) => pp, + Err(e) => { + return error_and_return(logger, e); + } + }; + provisioning_profiles.insert(app_id.identifier.clone(), provisioning_profile); } logger.log("Successfully registered app groups"); - let provisioning_profile = match dev_session - .download_team_provisioning_profile(DeveloperDeviceType::Ios, &team, &main_app_id) - .await - { - Ok(pp /* tee hee */) => pp, - Err(e) => { - return error_and_return(logger, e); - } - }; - - let profile_path = config - .store_dir - .join(format!("{}.mobileprovision", main_app_id_str)); + let profile_path = app.bundle.bundle_dir.join("embedded.mobileprovision"); if profile_path.exists() { std::fs::remove_file(&profile_path).map_err(Error::Filesystem)?; @@ -386,37 +372,53 @@ pub async fn sideload_app( ext.write_info()?; } - // match ZSignOptions::new(app.bundle.bundle_dir.to_str().unwrap()) - // .with_cert_file(cert.get_certificate_file_path().to_str().unwrap()) - // .with_pkey_file(cert.get_private_key_file_path().to_str().unwrap()) - // .with_prov_file(profile_path.to_str().unwrap()) - // .sign() - // { - // Ok(_) => {} - // Err(e) => { - // return error_and_return(logger, Error::ZSignError(e)); - // } - // }; + // Collect owned bundle identifiers and directories so we don't capture `app` or `logger` by reference in the blocking thread. + let embedded_bundles_info: Vec<(String, PathBuf)> = app + .bundle + .embedded_bundles() + .iter() + .map(|bundle| { + ( + bundle.bundle_identifier().unwrap_or("Unknown").to_string(), + bundle.bundle_dir.clone(), + ) + }) + .collect(); + let main_bundle_dir = app.bundle.bundle_dir.clone(); - let mut signer = BundleSigner::new_from_path(&app.bundle.bundle_dir) - .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; + // Log bundle signing messages outside the blocking closure to avoid capturing non-'static references. + for (id, _) in &embedded_bundles_info { + logger.log(&format!("Signing bundle: {}", id)); + } - signer - .collect_nested_bundles() - .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; + // Move owned data (cert, provisioning_profile, embedded_bundles_info) into the blocking task. + tokio::task::spawn_blocking(move || { + for (_id, bundle_dir) in embedded_bundles_info { + // Recreate settings for each bundle so ownership is clear and we don't move settings across iterations. + let mut settings = cert.to_signing_settings()?; + settings + .set_entitlements_xml( + SettingsScope::Main, + provisioning_profile.entitlements_xml()?, + ) + .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; - let mut settings = SigningSettings::default(); + let signer = UnifiedSigner::new(settings); - settings.set_signing_key(cert.private_key.clone(), cert.certificate.unwrap()); + signer + .sign_path_in_place(&bundle_dir) + .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; + } + Ok::<(), Error>(()) + }) + .await + .map_err(|e| Error::Generic(format!("Signing task failed: {}", e)))??; - signer - .write_signed_bundle(&app.bundle.bundle_dir, &settings) - .map_err(|e| Error::AppleCodesignError(Box::new(e)))?; - logger.log("App signed!"); + logger.log("Sucessfully signed app"); - logger.log("Installing app (Transfer)... 0%"); + logger.log("Installing app... 0%"); - let res = install_app(device_provider, &app.bundle.bundle_dir, |percentage| { + let res = install_app(device_provider, &main_bundle_dir, |percentage| { logger.log(&format!("Installing app... {}%", percentage)); }) .await; @@ -424,12 +426,12 @@ pub async fn sideload_app( return error_and_return(logger, e); } - if config.revoke_cert { - dev_session - .revoke_development_cert(DeveloperDeviceType::Ios, &team, &cert.get_serial_number()?) - .await?; - logger.log("Certificate revoked"); - } + // if config.revoke_cert { + // dev_session + // .revoke_development_cert(DeveloperDeviceType::Ios, &team, &cert.get_serial_number()?) + // .await?; + // logger.log("Certificate revoked"); + // } Ok(()) }