Implement cert revoke and prompt modes

This commit is contained in:
nab138
2026-02-09 09:24:44 -05:00
parent ec9af89d74
commit 78eb7fdfcc
8 changed files with 259 additions and 73 deletions

8
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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,8 +73,7 @@ async fn main() {
.unwrap()
.to_provider(UsbmuxdAddr::from_env_var().unwrap(), "isideload-demo");
let mut sideloader = SideloaderBuilder::new(dev_session, apple_id.to_string())
.team_selection(TeamSelection::Prompt(|teams| {
let team_selection_prompt = |teams: &Vec<DeveloperTeam>| {
println!("Please select a team:");
for (index, team) in teams.iter().enumerate() {
println!(
@@ -88,7 +90,41 @@ async fn main() {
return None;
}
Some(teams[selection - 1].team_id.clone())
}))
};
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<_>>(),
)
};
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))
.build();
let result = sideloader.install_app(&provider, app_path).await;

View File

@@ -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"

View File

@@ -63,7 +63,6 @@ impl AppleAccount {
anisette_generator: AnisetteDataGenerator,
debug: bool,
) -> Result<Self, Report> {
info!("Initializing apple account");
if debug {
warn!("Debug mode enabled: this is a security risk!");
}

View File

@@ -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<DevelopmentCertificate>) -> Option<Vec<DevelopmentCertificate>>),
}
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())),

View File

@@ -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<Option<Self>, Report> {
) -> Result<Option<(DevelopmentCertificate, X509Certificate)>, 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,21 +134,29 @@ impl CertificateIdentity {
developer_session: &mut DeveloperSession,
team: &DeveloperTeam,
max_certs_behavior: &MaxCertsBehavior,
) -> Result<Self, Report> {
) -> 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<Vec<DevelopmentCertificate>> = None;
// TODO: Handle max certs behavior properly instead of just always revoking
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"))?;
.ok_or_else(|| {
report!("Failed to find certificate after submitting CSR")
})?;
let x509_cert = X509Certificate::from_der(
apple_cert
@@ -144,11 +166,45 @@ impl CertificateIdentity {
.as_ref(),
)?;
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,
})
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(),
),
&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<String, Report> {
@@ -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<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");
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(())
}
}
}
}

View File

@@ -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<Option<String>, 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<Option<Vec<u8>>, Report> {
let entry = Entry::new(&self.service_name, key)?;
match entry.get_secret() {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
}

View File

@@ -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<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, "")
}
@@ -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"))]
{