From 78eb7fdfccff7282d8127bb67676ea8d20f11607 Mon Sep 17 00:00:00 2001 From: nab138 Date: Mon, 9 Feb 2026 09:24:44 -0500 Subject: [PATCH] Implement cert revoke and prompt modes --- Cargo.lock | 8 +- examples/minimal/src/main.rs | 76 ++++++++--- isideload/Cargo.toml | 2 +- isideload/src/auth/apple_account.rs | 1 - isideload/src/sideload/builder.rs | 16 ++- isideload/src/sideload/certificate.rs | 177 ++++++++++++++++++++------ isideload/src/util/keyring_storage.rs | 36 +++++- isideload/src/util/storage.rs | 16 ++- 8 files changed, 259 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d339c76..689420d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1793,9 +1793,9 @@ dependencies = [ [[package]] name = "rootcause" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a751633dcb95a6b1c954f0fa15c2afd9b4802640f8045432f68a1f4bde4b871" +checksum = "03621279b1bafd0cd806d4a4e301530bfab4a54a9a572ea45a4fe5072c3e134b" dependencies = [ "hashbrown 0.16.1", "indexmap", @@ -1806,9 +1806,9 @@ dependencies = [ [[package]] name = "rootcause-internals" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9eeddca0d656f1a58ce3fc3f41b0b877a7e760460108712ad39b60181fdcb3e" +checksum = "2a6575ad7db4a6f026820de38c377b3e06fc59ceac225f868dfede39cd70e432" dependencies = [ "triomphe", ] diff --git a/examples/minimal/src/main.rs b/examples/minimal/src/main.rs index ee54c99..0ab8b1e 100644 --- a/examples/minimal/src/main.rs +++ b/examples/minimal/src/main.rs @@ -4,8 +4,11 @@ use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}; use isideload::{ anisette::remote_v3::RemoteV3AnisetteProvider, auth::apple_account::AppleAccount, - dev::developer_session::DeveloperSession, - sideload::{SideloaderBuilder, TeamSelection}, + dev::{ + certificates::DevelopmentCertificate, developer_session::DeveloperSession, + teams::DeveloperTeam, + }, + sideload::{SideloaderBuilder, TeamSelection, builder::MaxCertsBehavior}, }; use tracing::Level; @@ -70,25 +73,58 @@ async fn main() { .unwrap() .to_provider(UsbmuxdAddr::from_env_var().unwrap(), "isideload-demo"); + let team_selection_prompt = |teams: &Vec| { + println!("Please select a team:"); + for (index, team) in teams.iter().enumerate() { + println!( + "{}: {} ({})", + index + 1, + team.name.as_deref().unwrap_or(""), + team.team_id + ); + } + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + let selection = input.trim().parse::().ok()?; + if selection == 0 || selection > teams.len() { + return None; + } + Some(teams[selection - 1].team_id.clone()) + }; + + let cert_selection_prompt = |certs: &Vec| { + 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(""), + cert.machine_name.as_deref().unwrap_or(""), + ); + } + 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 = input + .trim() + .split(',') + .filter_map(|s| s.trim().parse::().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::>(), + ) + }; + let mut sideloader = SideloaderBuilder::new(dev_session, apple_id.to_string()) - .team_selection(TeamSelection::Prompt(|teams| { - println!("Please select a team:"); - for (index, team) in teams.iter().enumerate() { - println!( - "{}: {} ({})", - index + 1, - team.name.as_deref().unwrap_or(""), - team.team_id - ); - } - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); - let selection = input.trim().parse::().ok()?; - if selection == 0 || selection > teams.len() { - return None; - } - Some(teams[selection - 1].team_id.clone()) - })) + .team_selection(TeamSelection::Prompt(team_selection_prompt)) + .max_certs_behavior(MaxCertsBehavior::Prompt(cert_selection_prompt)) .build(); let result = sideloader.install_app(&provider, app_path).await; diff --git a/isideload/Cargo.toml b/isideload/Cargo.toml index 0a1f44c..d36264c 100644 --- a/isideload/Cargo.toml +++ b/isideload/Cargo.toml @@ -27,7 +27,7 @@ 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.11.1" +rootcause = "0.12.0" futures-util = "0.3.31" serde_json = "1.0.149" base64 = "0.22.1" diff --git a/isideload/src/auth/apple_account.rs b/isideload/src/auth/apple_account.rs index 2524cb8..13f771b 100644 --- a/isideload/src/auth/apple_account.rs +++ b/isideload/src/auth/apple_account.rs @@ -63,7 +63,6 @@ impl AppleAccount { anisette_generator: AnisetteDataGenerator, debug: bool, ) -> Result { - info!("Initializing apple account"); if debug { warn!("Debug mode enabled: this is a security risk!"); } diff --git a/isideload/src/sideload/builder.rs b/isideload/src/sideload/builder.rs index c305767..0f243ea 100644 --- a/isideload/src/sideload/builder.rs +++ b/isideload/src/sideload/builder.rs @@ -1,7 +1,10 @@ use std::fmt::Display; use crate::{ - dev::{developer_session::DeveloperSession, teams::DeveloperTeam}, + dev::{ + certificates::DevelopmentCertificate, developer_session::DeveloperSession, + teams::DeveloperTeam, + }, sideload::sideloader::Sideloader, util::storage::SideloadingStorage, }; @@ -27,10 +30,12 @@ impl Display for TeamSelection { } pub enum MaxCertsBehavior { - /// If the maximum number of certificates is reached, delete all existing certificates and create a new one + /// 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) -> Option>), } pub struct SideloaderBuilder { @@ -69,12 +74,17 @@ impl SideloaderBuilder { self } + pub fn max_certs_behavior(mut self, behavior: MaxCertsBehavior) -> Self { + self.max_certs_behavior = Some(behavior); + self + } + 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::Revoke), + 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())), diff --git a/isideload/src/sideload/certificate.rs b/isideload/src/sideload/certificate.rs index be21d2f..b8b511c 100644 --- a/isideload/src/sideload/certificate.rs +++ b/isideload/src/sideload/certificate.rs @@ -11,8 +11,11 @@ use tracing::{error, info}; use x509_certificate::X509Certificate; use crate::{ + SideloadError, dev::{ - certificates::CertificatesApi, developer_session::DeveloperSession, teams::DeveloperTeam, + certificates::{CertificatesApi, DevelopmentCertificate}, + developer_session::DeveloperSession, + teams::DeveloperTeam, }, sideload::builder::MaxCertsBehavior, util::storage::SideloadingStorage, @@ -22,6 +25,7 @@ pub struct CertificateIdentity { pub machine_id: String, pub machine_name: String, pub certificate: X509Certificate, + pub private_key: RsaPrivateKey, } impl CertificateIdentity { @@ -36,23 +40,37 @@ impl CertificateIdentity { let pr = Self::retrieve_private_key(apple_email, storage).await?; let found = Self::find_matching(&pr, machine_name, developer_session, team).await; - if let Ok(Some(cert)) = found { + if let Ok(Some((cert, x509_cert))) = found { info!("Found matching certificate"); - return Ok(cert); + 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, + }); } if let Err(e) = found { error!("Failed to check for matching certificate: {:?}", e); } info!("Requesting new certificate"); - Self::request_certificate( + let (cert, x509_cert) = Self::request_certificate( &pr, machine_name.to_string(), developer_session, team, max_certs_behavior, ) - .await + .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, + }) } async fn retrieve_private_key( @@ -63,16 +81,16 @@ impl CertificateIdentity { hasher.update(apple_email.as_bytes()); let email_hash = hex::encode(hasher.finalize()); - let private_key = storage.retrieve(&format!("{}/key", email_hash))?; + let private_key = storage.retrieve_data(&format!("{}/key", email_hash))?; if private_key.is_some() { - return Ok(RsaPrivateKey::from_pkcs8_pem(&private_key.unwrap())?); + return Ok(RsaPrivateKey::from_pkcs8_der(&private_key.unwrap())?); } let mut rng = rand::rng(); let private_key = RsaPrivateKey::new(&mut rng, 2048)?; - storage.store( + storage.store_data( &format!("{}/key", email_hash), - &private_key.to_pkcs8_pem(Default::default())?.to_string(), + &private_key.to_pkcs8_der()?.as_bytes(), )?; Ok(private_key) @@ -83,7 +101,7 @@ impl CertificateIdentity { machine_name: &str, developer_session: &mut DeveloperSession, team: &DeveloperTeam, - ) -> Result, Report> { + ) -> Result, Report> { let public_key_der = private_key .to_public_key() .to_pkcs1_der()? @@ -103,11 +121,7 @@ impl CertificateIdentity { X509Certificate::from_der(cert.cert_content.as_ref().unwrap().as_ref())?; if public_key_der == x509_cert.public_key_data().as_ref() { - return Ok(Some(Self { - machine_id: cert.machine_id.clone().unwrap_or_default(), - machine_name: cert.machine_name.clone().unwrap_or_default(), - certificate: x509_cert, - })); + return Ok(Some((cert.clone(), x509_cert))); } } @@ -120,35 +134,77 @@ impl CertificateIdentity { developer_session: &mut DeveloperSession, team: &DeveloperTeam, max_certs_behavior: &MaxCertsBehavior, - ) -> Result { + ) -> Result<(DevelopmentCertificate, X509Certificate), Report> { let csr = Self::build_csr(private_key).context("Failed to generate CSR")?; - let request = developer_session - .submit_development_csr(team, csr, machine_name, None) - .await?; + let mut i = 0; + let mut existing_certs: Option> = None; - // TODO: Handle max certs behavior properly instead of just always revoking + while i < 4 { + i += 1; - let apple_certs = developer_session.list_ios_certs(team).await?; + let result = developer_session + .submit_development_csr(team, csr.clone(), machine_name.clone(), None) + .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"))?; + match result { + Ok(request) => { + let apple_certs = developer_session.list_ios_certs(team).await?; - let x509_cert = X509Certificate::from_der( - apple_cert - .cert_content - .as_ref() - .ok_or_else(|| report!("Certificate content missing"))? - .as_ref(), - )?; + 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") + })?; - Ok(Self { - machine_id: apple_cert.machine_id.clone().unwrap_or_default(), - machine_name: apple_cert.machine_name.clone().unwrap_or_default(), - certificate: x509_cert, - }) + let x509_cert = X509Certificate::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::()); + 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(), + ), + &mut 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 { @@ -169,4 +225,51 @@ impl CertificateIdentity { Ok(params.serialize_request(&subject_key)?.pem()?) } + + async fn revoke_others( + developer_session: &mut DeveloperSession, + team: &DeveloperTeam, + max_certs_behavior: &MaxCertsBehavior, + error: SideloadError, + existing_certs: &mut Vec, + ) -> 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"); + return 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(()) + } + } + } } diff --git a/isideload/src/util/keyring_storage.rs b/isideload/src/util/keyring_storage.rs index bc0f495..7ff4716 100644 --- a/isideload/src/util/keyring_storage.rs +++ b/isideload/src/util/keyring_storage.rs @@ -2,22 +2,32 @@ use crate::util::storage::SideloadingStorage; use keyring::Entry; use rootcause::prelude::*; -pub struct KeyringStorage {} +pub struct KeyringStorage { + pub service_name: String, +} impl KeyringStorage { - pub fn new() -> Self { - 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("isideload", key)?.set_password(value)?; + Entry::new(&self.service_name, key)?.set_password(value)?; Ok(()) } fn retrieve(&self, key: &str) -> Result, Report> { - let entry = Entry::new("isideload", key)?; + let entry = Entry::new(&self.service_name, key)?; match entry.get_password() { Ok(password) => Ok(Some(password)), Err(keyring::Error::NoEntry) => Ok(None), @@ -26,11 +36,25 @@ impl SideloadingStorage for KeyringStorage { } fn delete(&self, key: &str) -> Result<(), Report> { - let entry = Entry::new("isideload", key)?; + let entry = Entry::new(&self.service_name, key)?; match entry.delete_credential() { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), } } + + fn store_data(&self, key: &str, value: &[u8]) -> Result<(), Report> { + Entry::new(&self.service_name, key)?.set_secret(value)?; + Ok(()) + } + + fn retrieve_data(&self, key: &str) -> Result>, 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()), + } + } } diff --git a/isideload/src/util/storage.rs b/isideload/src/util/storage.rs index 27fbb03..0480174 100644 --- a/isideload/src/util/storage.rs +++ b/isideload/src/util/storage.rs @@ -1,10 +1,24 @@ use std::{collections::HashMap, sync::Mutex}; +use base64::prelude::*; use rootcause::prelude::*; pub trait SideloadingStorage: Send + Sync { fn store(&self, key: &str, value: &str) -> Result<(), Report>; fn retrieve(&self, key: &str) -> Result, 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>, 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, "") } @@ -13,7 +27,7 @@ pub trait SideloadingStorage: Send + Sync { pub fn new_storage() -> impl SideloadingStorage { #[cfg(feature = "keyring-storage")] { - crate::util::keyring_storage::KeyringStorage::new() + crate::util::keyring_storage::KeyringStorage::default() } #[cfg(not(feature = "keyring-storage"))] {