Skip to content

feat: support signing and notarizing app bundles - #815

Open
TotallyGamerJet wants to merge 8 commits into
anchore:mainfrom
TotallyGamerJet:app-bundle-signing
Open

feat: support signing and notarizing app bundles#815
TotallyGamerJet wants to merge 8 commits into
anchore:mainfrom
TotallyGamerJet:app-bundle-signing

Conversation

@TotallyGamerJet

Copy link
Copy Markdown

Closes #550

Adds the ability to sign and notarize macOS application bundles (.app directories), in addition to plain Mach-O binaries. The implementation is modeled on apple-codesign (Rust), which implements bundle signing without relying on macOS.

How it works

quill sign My.app (and the underlying quill.Sign) now detects app bundles and:

  1. Signs nested code first — any Mach-O binaries found in nested-code locations (MacOS/, Frameworks/, PlugIns/, etc., per Apple's resource rules) are signed in place with the same signing material.
  2. Seals bundle resources — every file in the bundle is sealed into Contents/_CodeSignature/CodeResources: regular files by SHA-1 (files) + SHA-256 (files2) content hashes, symlinks by their target, and nested binaries by their code directory hash (cdhash) and designated requirement text. The rules embedded in the seal are the same version 1/version 2 rule sets Apple's codesign emits.
  3. Signs the main executable — with the SHA-256 hashes of Info.plist and CodeResources bound into code directory special slots 1 and 3, and CFBundleIdentifier as the default signing identity (matching codesign behavior; --identity still overrides).

Notarization also accepts bundles: quill notarize My.app zips the bundle for submission with the same layout as ditto -c -k --keepParent (top-level bundle entry, symlinks and file modes preserved), and IsSigned now reports on bundles by checking the resource seal and main executable.

New quill/bundle package holds bundle detection, Info.plist parsing, resource rules, and the CodeResources builder. One new dependency: howett.net/plist (BSD) for plist parsing/serialization.

Verification

  • Unit tests for bundle detection, rule precedence/matching, and the resources builder (golden CodeResources comparison), plus zip payload round-trip tests.
  • Integration tests sign bundles ad-hoc, with a certificate, and with nested binaries, and verify the results with Apple's codesign -vvv --verify --deep --strict (and debug output assertions), alongside the existing binary signing tests.
  • Manually verified end-to-end with a real Cocoa app (Go + purego): quill-signed bundle passes codesign deep/strict verification, including validation of a nested helper binary against its sealed requirement, and the notarization zip round-trips losslessly (unzip → deep verify passes; entry list matches ditto output).

Limitations (possible follow-ups)

  • Nested bundles (frameworks, nested .apps) are not yet supported and fail with a clear error; nested plain Mach-O binaries (dylibs, helpers) are fully supported. This covers the common Go/CGo app layout.
  • Stapling is not implemented (it never was, for binaries either) — notarized apps validate via Gatekeeper's online ticket lookup. The resource rules already exclude Contents/CodeResources, so stapled tickets won't invalidate the seal.

Also includes a small standalone fix: a failure while updating superblob offset references was silently swallowed (return nil), which could produce a corrupt binary with no error.

🤖 Generated with Claude Code

@oss-housekeeper oss-housekeeper Bot added the dependencies dealing with project dependencies label Jul 17, 2026
TotallyGamerJet and others added 8 commits July 17, 2026 16:46
Adds a new quill/bundle package that can detect .app bundles, parse
Info.plist, and seal bundle contents into a CodeResources plist using
the same resource rules Apple's codesign tool embeds. Wires bundle
signing into quill.Sign: nested Mach-O binaries are signed in place and
sealed by cdhash/requirement, and the main executable is signed with the
Info.plist and CodeResources hashes bound into its code directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Adds integration tests that sign .app bundles (ad-hoc, with a
certificate, and with nested binaries) and verify the results with
Apple's codesign tool, and updates the sign command help text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Bundles submitted for notarization are zipped with the bundle directory
as the top-level entry (matching 'ditto -c -k --keepParent'), preserving
symlinks and file modes. IsSigned now reports on bundles by checking the
resource seal and the main executable signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Previously a failure here was silently swallowed (returning nil),
which could produce a corrupt binary without any indication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Matches the zip layout produced by 'ditto -c -k --keepParent' exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
Drops the hand-rolled magic-number sniffing in the bundle package in
favor of the existing parser-based detection. IsMachoFile now reports
unparseable content as (false, nil) instead of returning the parse
error, reserving errors for IO failures -- this also makes the intended
'not a darwin macho executable' message in notary payload preparation
reachable for non-Mach-O input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: TotallyGamerJet <TotallyGamerJet@users.noreply.github.com>
themcfarland pushed a commit to themcfarland/mercury that referenced this pull request Aug 15, 2026
…esign

Three gaps in the macOS packaging, all visible next to the Windows side.

The image was always named Mercury.dmg, with nothing to distinguish 1.9.10
from 1.9.11 in a downloads folder or on a release page -- while Windows has
shipped mercury-$(MERCURY_VERSION)-w64-<hash>.zip and
Mercury_$(MERCURY_VERSION)_Setup.exe all along.  MERCURY_VERSION was already
parsed from common/mercury_version.h for exactly that purpose; the .dmg simply
never used it.  Now Mercury-1.9.11-universal.dmg.

The image also held only Mercury.app.  There is a universal CLI target, but
nothing copied its output in, so a Mac operator wanting a headless station --
a TNC for Winlink/BPQ32, or uucp -- had nothing to install.  The GUI is built
-tags mercury_embedded with the modem linked in, so the CLI is a genuinely
separate artifact.  It goes in a "Command Line" folder beside the .app with
mercury.ini.example and a README, so a drag-install still copies exactly one
thing and nothing extra lands inside the bundle to complicate signing.

And there was no signing.  Added opt-in signing via rcodesign
(github.com/indygreg/apple-platform-rs): the CLI, then the .app bundle, then
the image -- in that order, because signing a disk image does NOT sign what is
inside it.  Notarization is a separate target (macos-notarize-dmg) since it
needs the network and Apple's verdict.  Missing credentials warn instead of
failing, so developer builds are unchanged.

rcodesign rather than Apple's codesign or anchore/quill.  Quill was tried
first and rejected: it signs Mach-O binaries only, and cannot sign bundles or
disk images (anchore/quill#815, #550 both open, no CodeResources support in
its tree) -- which is everything Gatekeeper actually judges.  rcodesign covers
Mach-O, bundles, .dmg and .pkg, plus notarize and staple, in pure Rust with no
Mac, no Xcode and no keychain, so the signing certificate never has to reach a
macOS runner.  Note this does not remove macOS from the release entirely:
hdiutil builds the image and is Apple-only.  What moves off the Mac is signing
and notarization.

Two pre-existing bugs surfaced while testing this:

  - macos-universal never invoked internal_deps, so after its own `make clean`
    the link failed on a missing modem/freedv/libfreedvdata.a.  That target
    could not work on a clean tree; nothing had called it from the dmg path
    before, so it went unnoticed.
  - the two universal targets both run `make clean`, which removes `mercury`
    AND Mercury.app, so they cannot be ordered as prerequisites -- whichever
    ran second deleted the other's output.  The CLI is now built first and
    parked under a name clean does not match (and deliberately NOT added to
    clean, which would defeat it), cleared at the start of the recipe so a
    leftover from a failed run can never be staged as if fresh.

Tested on macOS 15.7.7 (x86_64):

  - image mounts and contains Mercury.app, Applications, and
    Command Line/{mercury,mercury.ini.example,README.txt}
  - both binaries report `lipo -archs` = x86_64 arm64
  - the CLI runs: "Mercury Version 1.9.11 (git 6dec3b0)"
  - a Mercury.app signed by rcodesign 0.28.0 ON LINUX passes Apple's own
    `codesign --verify --deep --strict`: "valid on disk", "satisfies its
    Designated Requirement", with CodeResources written and the universal
    Mach-O sealed
  - the .dmg signed on Linux likewise verifies and still mounts

Signed with a self-signed test certificate, so spctl still rejects it -- that
is Gatekeeper judging the credential, not the signature.  A real Developer ID
plus notarization is the remaining gate, and is a credentials matter rather
than a code one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies dealing with project dependencies

Projects

None yet

Development

Successfully merging this pull request may close these issues.

signing app bundles

1 participant