Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
apple-codesign-quick
src
bundle.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
bundle.rs
Blame
History
Raw
use crate::error::{CodeSignError, Result}; use crate::macho::{DEFAULT_CMS_BLOB_RESERVATION, MachOSigningConfig, sign_macho_file}; use crate::signature::CmsSigner; use code_resources::FileHash; use plist::Dictionary; use rayon::prelude::*; use std::collections::BTreeMap; use std::ffi::OsStr; use std::fs; use std::path::{Component, Path, PathBuf}; mod code_resources; pub use code_resources::CodeResourcesMaps; const FAIRPLAY_DIR: &str = "SC_Info"; const CODE_SIGNATURE_DIR: &str = "_CodeSignature"; const CODE_RESOURCES_FILE: &str = "CodeResources"; const PROVISIONING_PROFILE_FILE: &str = "embedded.mobileprovision"; // Wherever they are (Frameworks/, PlugIns/, Watch/, AppClips/, Extensions/...), these get their own signature. const NESTED_BUNDLE_EXTENSIONS: [&str; 3] = ["app", "appex", "framework"]; // Real apps nest about four deep (app, Watch app, extension, framework); a crafted IPA shouldn't // get to recurse until the stack runs out. const MAX_NESTING_DEPTH: usize = 16; pub struct BundleSigningSettings<'a> { pub team_id: String, pub main_entitlements: Dictionary, pub entitlements_by_bundle_id: BTreeMap<String, Dictionary>, pub cms_signer: Option<&'a dyn CmsSigner>, pub embedded_mobileprovision: Option<&'a [u8]>, pub embedded_mobileprovisions_by_bundle_id: BTreeMap<String, &'a [u8]>, pub cms_blob_reservation: usize, } impl<'a> BundleSigningSettings<'a> { pub fn new( team_id: impl Into<String>, main_entitlements: Dictionary, cms_signer: Option<&'a dyn CmsSigner>, ) -> Self { BundleSigningSettings { team_id: team_id.into(), main_entitlements, entitlements_by_bundle_id: BTreeMap::new(), cms_signer, embedded_mobileprovision: None, embedded_mobileprovisions_by_bundle_id: BTreeMap::new(), cms_blob_reservation: DEFAULT_CMS_BLOB_RESERVATION, } } fn macho_config<'b>( &'b self, identifier: &'b str, entitlements: &'b Dictionary, ) -> MachOSigningConfig<'b> { MachOSigningConfig { cms_blob_reservation: self.cms_blob_reservation, ..MachOSigningConfig::new(identifier, &self.team_id, entitlements, self.cms_signer) } } } #[derive(Debug)] pub struct Bundle { path: PathBuf, app_info: Dictionary, } impl Bundle { pub fn open(path: impl Into<PathBuf>) -> Result<Self> { let path = path.into(); let info_path = path.join("Info.plist"); reject_symlink(&info_path)?; if !info_path.is_file() { return Err(CodeSignError::MissingInfoPlist(info_path)); } let info_data = fs::read(&info_path).map_err(CodeSignError::io(&info_path))?; let app_info = plist::from_bytes(&info_data)?; Ok(Bundle { path, app_info }) } pub fn path(&self) -> &Path { &self.path } pub fn bundle_identifier(&self) -> Result<&str> { self.info_string("CFBundleIdentifier") } pub fn executable_name(&self) -> Result<&str> { self.info_string("CFBundleExecutable") } /// Sign the bundle and every bundle nested in it, innermost first. pub fn sign(&mut self, settings: &BundleSigningSettings<'_>) -> Result<CodeResourcesMaps> { self.sign_base(settings, 0) } fn sign_base( &self, settings: &BundleSigningSettings<'_>, depth: usize, ) -> Result<CodeResourcesMaps> { if depth > MAX_NESTING_DEPTH { return Err(CodeSignError::InvalidBundlePath(self.path.clone())); } let is_root = depth == 0; let bundle_id = self.bundle_identifier()?; let executable_path = bundle_file(&self.path, self.executable_name()?)?; // Decrypted App Store apps still carry their FairPlay files, which are not ours to seal. let fairplay_dir = self.path.join(FAIRPLAY_DIR); if fairplay_dir.exists() { fs::remove_dir_all(&fairplay_dir).map_err(CodeSignError::io(&fairplay_dir))?; } let code_signature_dir = self.path.join(CODE_SIGNATURE_DIR); reject_symlink(&code_signature_dir)?; fs::create_dir_all(&code_signature_dir).map_err(CodeSignError::io(&code_signature_dir))?; let code_resources_path = code_signature_dir.join(CODE_RESOURCES_FILE); if code_resources_path.exists() { fs::remove_file(&code_resources_path) .map_err(CodeSignError::io(&code_resources_path))?; } let info_plist = { let mut data = Vec::new(); plist::to_writer_binary(&mut data, &self.app_info)?; data }; write_file(&self.path.join("Info.plist"), &info_plist)?; let mobileprovision = settings .embedded_mobileprovisions_by_bundle_id .get(bundle_id) .copied() .or(settings.embedded_mobileprovision.filter(|_| is_root)); if let Some(mobileprovision) = mobileprovision { write_file(&self.path.join(PROVISIONING_PROFILE_FILE), mobileprovision)?; } let contents = BundleContents::collect(&self.path, &executable_path)?; let (nested, resources): (Result<Vec<_>>, Result<Vec<_>>) = rayon::join( || { contents .nested .par_iter() .map(|nested| nested.sign_base(settings, depth + 1)) .collect() }, || { sign_libraries(&contents.libraries, settings)?; contents .resources .par_iter() .map(|path| FileHash::of_file(&self.path, path)) .collect() }, ); let mut code_resources = CodeResourcesMaps::default(); for (bundle, nested) in contents.nested.iter().zip(nested?) { code_resources.insert_nested( &self.path, &bundle.path, bundle.executable_name()?, nested, )?; } for hash in resources? { code_resources.insert_hash(hash); } let code_resources_data = code_resources.encode()?; write_file(&code_resources_path, &code_resources_data)?; let no_entitlements = Dictionary::new(); let entitlements = settings .entitlements_by_bundle_id .get(bundle_id) .or(is_root.then_some(&settings.main_entitlements)) .unwrap_or(&no_entitlements); let config = MachOSigningConfig { info_plist: Some(&info_plist), code_resources: Some(&code_resources_data), ..settings.macho_config(bundle_id, entitlements) }; sign_macho_file(&executable_path, &config)?; Ok(code_resources) } fn info_string(&self, key: &'static str) -> Result<&str> { self.app_info .get(key) .and_then(plist::Value::as_string) .ok_or(CodeSignError::MissingInfoString(key)) } } pub fn sign_bundle(path: impl Into<PathBuf>, settings: &BundleSigningSettings<'_>) -> Result<()> { Bundle::open(path)?.sign(settings)?; Ok(()) } /// What a bundle signs and seals besides its main executable, each list sorted by path. #[derive(Debug, Default)] struct BundleContents { nested: Vec<Bundle>, libraries: Vec<PathBuf>, resources: Vec<PathBuf>, } impl BundleContents { fn collect(bundle_path: &Path, executable_path: &Path) -> Result<Self> { let code_signature_dir = bundle_path.join(CODE_SIGNATURE_DIR); let mut contents = BundleContents::default(); let mut directories = vec![bundle_path.to_path_buf()]; while let Some(directory) = directories.pop() { for entry in fs::read_dir(&directory).map_err(CodeSignError::io(&directory))? { let entry = entry.map_err(CodeSignError::io(&directory))?; let path = entry.path(); let file_type = entry.file_type().map_err(CodeSignError::io(&path))?; // Symbolic links are neither, and are skipped. if file_type.is_dir() { match nested_bundle(&path) { Some(bundle) => contents.nested.push(bundle), None if path != code_signature_dir => directories.push(path), None => {} } } else if file_type.is_file() && path != executable_path { if path.extension() == Some(OsStr::new("dylib")) { contents.libraries.push(path.clone()); } contents.resources.push(path); } } } contents .nested .sort_by(|left, right| left.path.cmp(&right.path)); contents.libraries.sort(); contents.resources.sort(); Ok(contents) } } /// Open `path` if it is a nested bundle with code. Anything else, resource-only bundles and Info.plists /// we can't make sense of included, is sealed as plain files. fn nested_bundle(path: &Path) -> Option<Bundle> { let has_bundle_extension = path .extension() .and_then(OsStr::to_str) .is_some_and(|extension| { NESTED_BUNDLE_EXTENSIONS .iter() .any(|candidate| extension.eq_ignore_ascii_case(candidate)) }); has_bundle_extension .then(|| Bundle::open(path).ok()) .flatten() .filter(|bundle| bundle.bundle_identifier().is_ok() && bundle.executable_name().is_ok()) } fn sign_libraries(libraries: &[PathBuf], settings: &BundleSigningSettings<'_>) -> Result<()> { let no_entitlements = Dictionary::new(); libraries.par_iter().try_for_each(|path| { let identifier = path .file_name() .and_then(OsStr::to_str) .ok_or_else(|| CodeSignError::InvalidBundlePath(path.clone()))?; sign_macho_file(path, &settings.macho_config(identifier, &no_entitlements)) }) } /// `name` in the bundle, as long as it doesn't lead out of it: Info.plist comes from the IPA. fn bundle_file(bundle_path: &Path, name: &str) -> Result<PathBuf> { let path = bundle_path.join(name); let mut components = Path::new(name).components(); match (components.next(), components.next()) { (Some(Component::Normal(_)), None) => reject_symlink(&path).map(|()| path), _ => Err(CodeSignError::InvalidBundlePath(path)), } } // Same thing for symbolic links: we never write through them. fn reject_symlink(path: &Path) -> Result<()> { if path.is_symlink() { Err(CodeSignError::InvalidBundlePath(path.to_path_buf())) } else { Ok(()) } } fn write_file(path: &Path, data: &[u8]) -> Result<()> { reject_symlink(path)?; fs::write(path, data).map_err(CodeSignError::io(path)) } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::macho::{parse_macho_entitlements, tests::test_macho}; use plist::Value; use sha1::Digest as _; pub(crate) fn test_dir(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("The clock should be after 1970.") .as_nanos(); std::env::temp_dir().join(format!( "apple-codesign-{name}-{}-{nanos}", std::process::id() )) } fn write_bundle(path: &Path, bundle_id: &str, executable: Option<&str>) { fs::create_dir_all(path).unwrap(); let mut info = Dictionary::from_iter([("CFBundleIdentifier", bundle_id)]); if let Some(executable) = executable { info.insert("CFBundleExecutable".to_string(), executable.into()); fs::write(path.join(executable), test_macho()).unwrap(); } plist::to_file_xml(path.join("Info.plist"), &info).unwrap(); } fn entitlements(bundle_id: &str) -> Dictionary { Dictionary::from_iter([("application-identifier", format!("TEAMID.{bundle_id}"))]) } /// Nested bundles in the order `Bundle::sign` finishes them: children before their parent. fn signing_order(bundle: &Bundle) -> Vec<PathBuf> { let executable_path = bundle.path.join(bundle.executable_name().unwrap()); let contents = BundleContents::collect(&bundle.path, &executable_path).unwrap(); contents .nested .iter() .flat_map(|nested| { let mut order = signing_order(nested); order.push(nested.path.clone()); order }) .collect() } #[test] fn nested_bundles_are_signed_innermost_first() { let root = test_dir("nested-bundles"); let nested = [ ( "AppClips/Clip.app/Frameworks/ClipKit.framework", "com.example.app.Clip.ClipKit", "ClipKit", ), ("AppClips/Clip.app", "com.example.app.Clip", "Clip"), ("Extensions/Kit.appex", "com.example.app.Kit", "Kit"), ( "Frameworks/Shared.framework", "com.example.Shared", "Shared", ), ("PlugIns/Widget.appex", "com.example.app.Widget", "Widget"), ( "Watch/Watch.app/PlugIns/Watch Extension.appex/Frameworks/WatchKit.framework", "com.example.WatchKit", "WatchKit", ), ( "Watch/Watch.app/PlugIns/Watch Extension.appex", "com.example.app.watchkitapp.watchkitextension", "Watch Extension", ), ("Watch/Watch.app", "com.example.app.watchkitapp", "Watch"), ]; write_bundle(&root, "com.example.app", Some("App")); for (path, bundle_id, executable) in nested { write_bundle(&root.join(path), bundle_id, Some(executable)); } // A resource bundle and a loose library are sealed by their parent, not signed on their own. write_bundle( &root.join("Resources.bundle"), "com.example.app.resources", None, ); write_bundle( &root.join("Frameworks/Codeless.framework"), "com.example.codeless", None, ); let broken = root.join("Resources.bundle/Broken.framework"); fs::create_dir_all(&broken).unwrap(); fs::write(broken.join("Info.plist"), b"not a property list").unwrap(); fs::write(root.join("Frameworks/libPatch.dylib"), test_macho()).unwrap(); let bundle = Bundle::open(&root).unwrap(); let expected_order: Vec<PathBuf> = nested.iter().map(|(path, ..)| root.join(path)).collect(); assert_eq!(signing_order(&bundle), expected_order); let mut settings = BundleSigningSettings::new("TEAMID", entitlements("com.example.app"), None); settings.entitlements_by_bundle_id = nested .iter() .map(|(_, bundle_id, _)| (bundle_id.to_string(), entitlements(bundle_id))) .collect(); Bundle::open(&root).unwrap().sign(&settings).unwrap(); let all_bundles = nested .iter() .map(|(path, bundle_id, executable)| (root.join(path), *bundle_id, *executable)) .chain([(root.clone(), "com.example.app", "App")]); for (path, bundle_id, executable) in all_bundles { let executable = path.join(executable); let embedded = parse_macho_entitlements(&executable, &fs::read(&executable).unwrap()).unwrap(); assert_eq!( embedded, Some(entitlements(bundle_id)), "{bundle_id} has the wrong entitlements." ); // Every enclosing bundle sealed the final, signed executable: it was signed before them. let signed_sha256 = sha2::Sha256::digest(fs::read(&executable).unwrap()).to_vec(); for parent in path .ancestors() .skip(1) .filter(|parent| parent.starts_with(&root)) { let sealed_path = parent.join(CODE_SIGNATURE_DIR).join(CODE_RESOURCES_FILE); if !sealed_path.exists() { continue; } let sealed: Dictionary = plist::from_file(&sealed_path).unwrap(); let relative = code_resources::relative_path(parent, &executable).unwrap(); let hash2 = sealed["files2"] .as_dictionary() .and_then(|files2| files2.get(&relative)) .and_then(Value::as_dictionary) .and_then(|entry| entry.get("hash2")) .and_then(Value::as_data); assert_eq!( hash2, Some(signed_sha256.as_slice()), "{relative} is not sealed by {}.", parent.display() ); } } let sealed: Dictionary = plist::from_file(root.join("_CodeSignature/CodeResources")).unwrap(); let files2 = sealed["files2"].as_dictionary().unwrap(); assert!(files2.contains_key("Resources.bundle/Info.plist")); assert!(files2.contains_key("Frameworks/Codeless.framework/Info.plist")); assert!(files2.contains_key("Resources.bundle/Broken.framework/Info.plist")); assert!(files2.contains_key("Frameworks/libPatch.dylib")); assert!( parse_macho_entitlements( Path::new("libPatch.dylib"), &fs::read(root.join("Frameworks/libPatch.dylib")).unwrap() ) .unwrap() .is_some() ); fs::remove_dir_all(root).unwrap(); } #[test] fn executables_and_symlinks_cannot_lead_out_of_the_bundle() { let root = test_dir("escapes"); write_bundle(&root, "com.example.app", Some("App")); let mut info: Dictionary = plist::from_file(root.join("Info.plist")).unwrap(); info.insert("CFBundleExecutable".to_string(), "../App".into()); plist::to_file_xml(root.join("Info.plist"), &info).unwrap(); let settings = BundleSigningSettings::new("TEAMID", Dictionary::new(), None); let error = Bundle::open(&root).unwrap().sign(&settings).unwrap_err(); assert!(matches!(error, CodeSignError::InvalidBundlePath(_))); info.insert("CFBundleExecutable".to_string(), "App".into()); plist::to_file_xml(root.join("Info.plist"), &info).unwrap(); let outside = test_dir("escapes-outside"); fs::create_dir_all(&outside).unwrap(); std::os::unix::fs::symlink(&outside, root.join(CODE_SIGNATURE_DIR)).unwrap(); let error = Bundle::open(&root).unwrap().sign(&settings).unwrap_err(); assert!(matches!(error, CodeSignError::InvalidBundlePath(_))); assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); fs::remove_file(root.join(CODE_SIGNATURE_DIR)).unwrap(); fs::rename(root.join("Info.plist"), outside.join("Info.plist")).unwrap(); std::os::unix::fs::symlink(outside.join("Info.plist"), root.join("Info.plist")).unwrap(); let error = Bundle::open(&root).unwrap_err(); assert!(matches!(error, CodeSignError::InvalidBundlePath(_))); fs::remove_dir_all(root).unwrap(); fs::remove_dir_all(outside).unwrap(); } #[test] fn direct_dylibs_are_hashed_from_their_final_signed_contents() { let test_root = test_dir("dylib-resources"); let dylib = test_root.join("Frameworks/Patch.dylib"); fs::create_dir_all(dylib.parent().unwrap()).unwrap(); fs::write(&dylib, b"unsigned").unwrap(); let contents = BundleContents::collect(&test_root, &test_root.join("Main")).unwrap(); assert_eq!(contents.libraries, vec![dylib.clone()]); assert_eq!(contents.resources, vec![dylib.clone()]); fs::write(&dylib, b"signed").unwrap(); let hash = FileHash::of_file(&test_root, &contents.resources[0]).unwrap(); let expected_sha256: [u8; 32] = sha2::Sha256::digest(b"signed").into(); assert_eq!(hash.sha256, expected_sha256); fs::remove_dir_all(test_root).unwrap(); } }