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
CHANGELOG.md
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
CHANGELOG.md
Blame
History
Raw
# Changelog ## Unreleased The public API is source-compatible: everything re-exported from `lib.rs`, the public modules with their pub fields and methods, and the CLI arguments are unchanged. The only API change is two new functions, listed under "Added". ### Fixed - **Nested bundles are all signed, innermost first.** Previously only the direct children of `Frameworks/` and `PlugIns/` were signed. Bundles in `Watch/`, `AppClips/` and `Extensions/` (ExtensionKit), and anything nested deeper (PlugIns of a Watch app, frameworks of an extension), kept their original signature, so iOS rejected the install. Now every `.app`, `.appex` and `.framework` with an executable is signed wherever it is. Each bundle seals the ones inside it, and each gets `entitlements_by_bundle_id` and `embedded_mobileprovisions_by_bundle_id` for its own bundle id. - Directories with an `Info.plist` but no `CFBundleExecutable` (`.bundle` resource bundles, codeless frameworks) directly under `Frameworks/` or `PlugIns/` made signing fail with `MissingInfoString`. They are now sealed as plain files. So is any `.app`, `.appex` or `.framework` whose Info.plist can't be read or parsed, or that has no `CFBundleIdentifier`: a stray broken plist inside a resource bundle no longer aborts the whole signing. - Files in subdirectories of `Frameworks/` and `PlugIns/` that are not bundles (for instance `Frameworks/assets/data.bin`) were left out of `CodeResources`, which iOS reports as a modified resource. They are sealed now. - Root files whose name merely starts with `SC_Info` (for instance `SC_Info.plist`) were left unsealed. Only the FairPlay `SC_Info/` directory is removed and skipped now. - Stale files left in a bundle's own `_CodeSignature/` were sealed into its `CodeResources`. That directory is now skipped, as Apple's codesign does. - An untrusted IPA could make the signer read or write outside the bundle in two ways: - a `CFBundleExecutable` such as `../x` or an absolute path; - a symbolic link at `Info.plist`, `embedded.mobileprovision`, `_CodeSignature` or the executable. Both are now refused with `InvalidBundlePath`; a symlinked `Info.plist` is refused before it is read. For the root bundle this happens before anything is written. A nested bundle is checked only when its turn comes, so by then its parents may already be partly rewritten. That leaves a half-signed copy, but nothing outside the bundle is touched. - Bundles nested more than 16 deep are refused with `InvalidBundlePath`. Real apps nest about four deep, and a crafted IPA could otherwise recurse until the stack overflows. - Mach-O parsing and signature placement: - `section_64` flags were read from `reserved1` (offset 68, not 64). Zerofill sections were therefore not recognised, and other sections could be mistaken for zerofill, which skewed the header-padding limit for a new `LC_CODE_SIGNATURE`. - When the first section started right at the end of the load commands (no header padding), the limit fell back to the whole file. A new load command could then overwrite code that happened to start with 16 zero bytes. That case is now `NeedsCodeSignatureAllocation`. - An `LC_CODE_SIGNATURE` with `datasize == 0` and a stale `dataoff` sized the reservation from that `dataoff` instead of where the signature goes (after `__LINKEDIT`). Signing then failed with "encoded signature exceeded reserved LC_CODE_SIGNATURE size", or reserved far too much. - A `__LINKEDIT` overlapping the load commands could panic (out-of-bounds write) or truncate the binary. A `__LINKEDIT` that was not the last segment silently lost the segments after it. Both are errors now. - Fat binaries with very large alignments panicked or allocated gigabytes, and a fat header with no architecture produced an 8-byte file. Both are errors now (alignments above 2^15, lipo's limit, are refused). - Overflowing `__TEXT`/segment bounds no longer panic in debug builds, and a fat architecture count that overflows on 32-bit hosts is an error. - An existing signature whose `dataoff` lies past the end of the file is refused, instead of zero-filling the binary up to it (up to 4 GiB). - A binary with no `LC_CODE_SIGNATURE` and trailing bytes after `__LINKEDIT` now plans its signature from where it lands, `align16(__LINKEDIT end)`, instead of from the file length. It no longer reserves code slots for bytes that get truncated. When `__LINKEDIT` ends the file, as it does with every linker output, the result is byte-identical to before. - Matching Apple's codesign, each checked against Apple's Security sources and real `CodeResources` files: - Files under a bundle's own `Base.lproj/` are no longer marked optional, because the weight-1010 `^Base\.lproj/` rule wins over `^.*\.lproj/`. A parent still marks a nested bundle's `Base.lproj/` files optional, since the rules apply to paths relative to the sealing bundle. This changes `CodeResources`, and so the root CodeDirectory, of any app with `Base.lproj`. - CodeDirectories without a CMS blob (`cms_signer: None`) now set `CS_ADHOC`, like codesign and rcodesign. The CLI always signs with CMS, so its output is unaffected. - `execSegLimit` is `__TEXT`'s filesize, not its end. The two are equal whenever `__TEXT` starts at offset 0, which is always the case in practice. - Negative entitlement integers are encoded as minimal two's-complement DER integers, instead of failing with `UnsupportedEntitlement`. - `RustCryptoCmsSigner::from_der` now checks that the private key belongs to the signer certificate, instead of producing a signature that only fails on the device. It compares the RSA modulus and exponent, not the encoding of the certificate's key. - The CLI prints errors with their message (`apple-codesign-quick: I/O error on …`) instead of their `Debug` form. The exit code is still 1. ### Added - `macho::read_macho_entitlements(path) -> Result<Option<Dictionary>>` and `macho::parse_macho_entitlements(path, data) -> Result<Option<Dictionary>>`, also re-exported from the crate root. They read the XML entitlements slot of a Mach-O's existing code signature, thin or fat. For fat binaries they prefer the arm64 slice, else the first slice that has entitlements. They return `Ok(None)` for an unsigned binary or a signature without entitlements, and `Err` for a malformed Mach-O or signature. This is for Super Sideloader's fallback when an IPA has no `embedded.mobileprovision`: `read_macho_entitlements(&app.join(executable))`. ### Internal changes - Module layout, flat as in the other crates: - `bundle.rs` holds the signing flow and nested-bundle discovery; - `bundle/code_resources.rs` holds the `CodeResources` maps, rules and file hashing; - `macho.rs` holds the public entry points; - `macho/thin.rs` holds the load commands and signature placement; - `macho/fat.rs` holds fat headers; - `signature.rs` holds the CodeDirectory and superblob; - `signature/entitlements.rs` holds the XML/DER entitlements and exec-segment flags. - Removed duplication: - one segment-layout table replaces the separate 32-bit and 64-bit parsers, `__LINKEDIT` growth and end computations; - one `CodeDirectoryLayout` serves both the size planning and the encoder, so they can't drift apart (the cause of the `datasize == 0` bug); - one `blob()` helper encodes every magic+length blob; - `sign_macho_data` and the file path share one `Cow`-based implementation; - the rule tables are data (`RULES`, `RULES2`); - the three ad-hoc notions of "nested bundle" (discovery, walker pruning, path-prefix filter) are now a single directory walk. - Errors: same variants, sentence-case messages, and point-free `map_err(CodeSignError::io(path))` and `map_err(CodeSignError::cms)` helpers replace the per-call closures and wrapper functions. One edge case now reports a different variant. A binary with no header padding *and* no usable `__LINKEDIT` used to get `NeedsCodeSignatureAllocation`; it now gets `MachO`, because the signature location is checked before the load command is added. - Performance, measured equal or slightly faster on the same input: - nested bundles are signed concurrently with the parent's own dylib signing and resource hashing; before, the parent's hashing waited for the nested bundles; - Mach-Os are read through mmap, which saves one full-size copy for fat binaries; - fat output is allocated once; - entitlements and `Info.plist` are serialised without deep-cloning their dictionaries; - a nested bundle's hashes are moved into its parent instead of cloned at each level; - code pages are hashed with `par_chunks(..).with_min_len(64)`. - Tests: synthetic nested-bundle tree (`Watch/`, `AppClips/`, `Extensions/`, `PlugIns/`, `Frameworks/`, a resource bundle, a codeless framework and a loose dylib). It checks the discovered set, the signing order, that every enclosing bundle sealed the final signed executable, and that each executable carries its bundle id's entitlements. There are also regression tests for every Mach-O fix above, the entitlements reader (thin, fat, mixed arm64 and armv7) and the superblob size planning. #### Output compared with the previous version I signed three IPAs (SideStore: Frameworks and PlugIns; Shazam: a Watch app with an extension and frameworks; Music: a Watch app) through the CLI before and after, with the same certificate and profile. The CMS signing time differs between any two runs, so I compared every CodeDirectory with its CodeResources slot masked, and every `CodeResources` with the hashes of Mach-O files masked. A parent seals its nested bundles' `CodeResources` byte for byte, and those contain the hashes of deeper Mach-Os, so those entries were masked too; each nested `CodeResources` is compared on its own. With that masking, two runs of the same binary give identical results. I compared three versions: the original, the rewrite with the nested-bundle fix only, and the final code. - Rewrite and nested-bundle fix: - SideStore: every CodeDirectory and every `CodeResources` is identical. - Shazam and Music: everything outside `Watch/` is identical. The differences all come from the nested-bundle fix: - the Watch app, its extension and its frameworks are now re-signed with our identity; - their FairPlay `SC_Info/` files are removed and their `Info.plist` is rewritten, like any other nested bundle's; - the root therefore seals their new contents, as nested bundles, before its own files. - With the codesign fixes on top: no CodeDirectory changes apart from its `CodeResources` slot. In each bundle's `CodeResources`, the only change is that entries under that bundle's own `Base.lproj/` are no longer optional (21 entries for SideStore's root, for instance). Parents still mark a nested bundle's `Base.lproj/` files optional, and they seal the changed nested `CodeResources`. ## Proposed API changes (needs review) None of these is implemented. Each would be a breaking change, so they are listed for the user to decide, and to pass on to the people outside who use the crate. 1. **Acronyms in names.** Rename `CmsSigner` → `CMSSigner`, `CmsSigningRequest` → `CMSSigningRequest`, `RustCryptoCmsSigner` → `RustCryptoCMSSigner`, `HashAlgorithm::{Sha1, Sha256}` → `{SHA1, SHA256}` and `CodeSignError::{Io, Cms}` → `{IO, CMS}`. This matches `ADIError`, `HTTPSession` and `URLBag` in the other crates. *Migration:* a search and replace. Re-exports under the old names could be kept for one release with `#[deprecated]` on type aliases (not possible for the trait, which can only be renamed). 2. **One error enum per operation instead of `CodeSignError`.** Today, Mach-O problems are `MachO { path, message: String }`. Callers can only display them, and `Cms(String)` and `ProvisioningProfile(String)` lose their source error. Sketch: ```rust pub enum MachOError { #[error("The Mach-O is truncated: {what} needs {required} bytes, there are {len}")] Truncated { what: &'static str, required: usize, len: usize }, #[error("Cannot add LC_CODE_SIGNATURE: no header padding left")] NoHeaderPadding, #[error("Unsupported Mach-O magic {0:#010x}")] UnsupportedMagic(u32), // ... } pub enum CMSError { #[error("Cannot encode the CMS signature: {0}")] DER(#[from] der::Error), ... } pub enum ProvisioningProfileError { #[error("Invalid CMS envelope: {0}")] DER(#[from] der::Error), MissingTeamID, ... } pub enum BundleSigningError { #[error("I/O error on {path}: {source}")] IO { path: PathBuf, source: io::Error }, #[error("Cannot sign {path}: {source}")] MachO { path: PathBuf, source: MachOError }, ... } ``` *Migration:* callers that only display the error (Super Sideloader) don't change. Matches on variants move to the enum of the operation they call. 3. **Composable per-bundle signing identities.** `BundleSigningSettings` spreads one concept over four fields. The root has special cases (`main_entitlements`, `embedded_mobileprovision`), and a nested app with no entry is silently signed with empty entitlements. Sketch: ```rust pub struct BundleSigningSettings<'a> { pub team_id: TeamID, pub cms_signer: Option<&'a dyn CMSSigner>, pub identities: &'a dyn BundleIdentities, pub cms_blob_reservation: usize, } /// What a bundle is signed with, by its bundle id. pub struct BundleIdentity<'a> { pub entitlements: Dictionary, pub mobileprovision: Option<&'a [u8]>, } pub trait BundleIdentities: Sync { /// None for a bundle that should not have entitlements, such as a framework. fn identity(&self, bundle: &Bundle) -> Result<Option<BundleIdentity<'_>>>; } // The common case stays a plain map, keyed by bundle id, the root included. impl BundleIdentities for BTreeMap<String, BundleIdentity<'_>> { ... } ``` Super Sideloader's fallback then becomes a small `BundleIdentities` that tries the profile map, then `read_macho_entitlements` on the bundle's executable. An `.app`/`.appex` without an identity can then be an error instead of an empty signature. *Migration:* build one `BTreeMap<String, BundleIdentity>` instead of filling `main_entitlements`, `entitlements_by_bundle_id`, `embedded_mobileprovision` and `embedded_mobileprovisions_by_bundle_id`. The root is the entry for its own bundle id. 4. **Expose the bundle steps.** Today the only entry point is "sign everything". Splitting it lets a caller list, filter or re-sign nested bundles without re-implementing discovery. Super Sideloader does this today, from the zip, in `read_nested_bundles`. ```rust impl Bundle { /// The bundles nested in this one with code of their own, sorted by path. pub fn nested_bundles(&self) -> Result<Vec<Bundle>>; /// The entitlements of the existing signature of the main executable. pub fn embedded_entitlements(&self) -> Result<Option<Dictionary>>; pub fn sign(&self, settings: &BundleSigningSettings<'_>) -> Result<CodeResourcesMaps>; // &self } ``` *Migration:* `sign(&mut self)` → `sign(&self)` only triggers `unused_mut` warnings for callers. The other two are additions. 5. **Plain data for profiles and the signer.** In line with "pub fields, few getters", and following the identifier newtypes of the other crates: - `ProvisioningProfile` gets pub fields (`plist`, `team_id: TeamID`, `entitlements`, `developer_certificates: Vec<Vec<u8>>`, `expiration_date`); - `certificate_chain_der()` is renamed to `signer_certificates_der`, because these are the certificates that signed the profile, not `DeveloperCertificates`. Callers easily confuse the two; - `RustCryptoCMSSigner` keeps its key private but gets a pub `signing_time: Option<SystemTime>` instead of `with_signing_time`/`without_signing_time`. *Migration:* `profile.team_id()` → `profile.team_id.as_ref()`, `signer.without_signing_time()` → `signer.signing_time = None`. 6. **A smaller `signature` module.** It exposes building blocks that only make sense together: `entitlements_blob`, `der_entitlements_blob`, `empty_requirements_blob`, `CodeDirectoryParams` with its raw `macho_data`/`code_limit`, and every `CSSLOT_*`/`CSMAGIC_*` constant. I propose: - keep `CMSSigner`, `CMSSigningRequest`, `EncodedCodeDirectory`, `HashAlgorithm`, `der_entitlements` and the `CS_EXECSEG_*` flags public; - make the blob encoders private behind `MachOSigningConfig`. *Migration:* direct users of the blob helpers (none known) would go through `sign_macho_data`. 7. **Smaller, possibly additive, follow-ups:** - guard against deeply nested property lists. `plist` 1.10 recurses without a depth limit, so an Info.plist or embedded entitlements with about 100k nested arrays overflows the stack. This was already true of `Bundle::open`; `read_macho_entitlements` inherits it. A fix would need a depth limit upstream, or a pre-scan before parsing; - support `FAT_MAGIC_64`, for both reading and signing; - seal symbolic links the way Apple does (`files2` entry `{symlink = target}`) instead of skipping them. The current behaviour is unchanged: they are left unsealed.