Notarizing And Stapling A Mac App For Direct Download

5 min readXcode & Tools, Apple

Jake Marsh
In this post
  1. Signing With Hardened Runtime And A Timestamp
  2. Inside Out, Without --deep
  3. Notarizing With A Stored Profile
  4. Staple The App Before It Goes In The DMG
  5. Then The DMG Gets The Same Treatment
  6. Checking What People Download

Every Mac app I sell from my own site goes out the same way. It’s signed with Developer ID, notarized, stapled, put in a DMG, and then the DMG gets signed, notarized and stapled too. The steps are well known. The order is where people get bitten, along with which file each step applies to.

Here’s the sequence my release scripts run, with the flags and the reason behind each one.

Signing With Hardened Runtime And A Timestamp#

Before the notary service accepts an upload, every executable in it has to be signed with Developer ID, have the hardened runtime turned on and carry a secure timestamp. The debug-only com.apple.security.get-task-allow entitlement gets it rejected.

If Xcode does the signing, ENABLE_HARDENED_RUNTIME: YES in the target’s settings turns on the hardened runtime, and exporting the archive with the developer-id method handles the Developer ID signature and the timestamp:

ExportOptions.plistXML
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>TEAMID</string>
Shell
xcodebuild -exportArchive -archivePath build/MyApp.xcarchive \
  -exportOptionsPlist build/ExportOptions.plist -exportPath build/export

The export re-signs every nested framework, XPC service and helper app with your Developer ID certificate, the runtime flag and a timestamp. After it finishes, my scripts make sure the debug entitlement didn’t slip through, because it’s quicker to catch that before uploading than to read about it in a notary log:

Shell
if codesign -d --entitlements - "$app" 2>/dev/null | grep -q "get-task-allow"; then
  echo "ERROR: exported app carries com.apple.security.get-task-allow" >&2; exit 1
fi

Inside Out, Without --deep#

Sometimes the signing has to happen outside Xcode, for instance when the build needs provisioning profiles and entitlements generated at release time. Then you sign each piece yourself, starting from the deepest code and working outward.

Order matters because a bundle’s signature records the signatures of the code nested inside it. Sign the app first and a framework second, and the app’s seal no longer matches what’s on disk.

The tempting shortcut is codesign --deep. man codesign marks it “DEPRECATED for signing as of macOS 13.0”, and the warning right under that is the real reason to skip it: “All signing options will be applied, in turn, to all nested content. This is almost never what you want.” Pass --entitlements along with --deep and your app’s entitlements end up on Sparkle’s XPC services too. Sparkle’s docs warn against --deep for exactly this reason: its downloader service is signed with entitlements of its own. It also only finds nested code in a fixed list of locations (Contents/Frameworks, Contents/XPCServices, Contents/Helpers, Contents/Library/LoginItems and a few more), and anything elsewhere goes unsigned.

Here’s the inside-out version for an app that embeds Sparkle 2, in the order Sparkle’s own docs give:

sign.shShell
IDENTITY="Developer ID Application: Your Name (TEAMID)"
cs=(codesign --force --timestamp --options runtime --sign "$IDENTITY")
fw="MyApp.app/Contents/Frameworks/Sparkle.framework"
fwv="$fw/Versions/$(readlink "$fw/Versions/Current")"

"${cs[@]}" "$fwv/XPCServices/Installer.xpc"
"${cs[@]}" --preserve-metadata=entitlements "$fwv/XPCServices/Downloader.xpc"
"${cs[@]}" "$fwv/Autoupdate"
"${cs[@]}" "$fwv/Updater.app"
"${cs[@]}" "$fw"
"${cs[@]}" --entitlements MyApp.entitlements MyApp.app

--preserve-metadata=entitlements keeps the entitlements Sparkle already gave its downloader service, and the app’s own entitlements only go on the last line. --deep is still the right flag when verifying, which comes up at the end.

Notarizing With A Stored Profile#

notarytool reads credentials from the keychain, so the password never goes in a script. Store them once:

Shell
xcrun notarytool store-credentials notary-profile \
  --apple-id you@example.com --team-id TEAMID

Leave out --password and it prompts for an app-specific password, then checks the credentials with Apple before saving them (--validate is the default).

notarytool submit takes a file, so the app goes up as a zip. ditto -c -k --keepParent makes a PKZip archive with MyApp.app as its top-level folder:

release.shShell
notarize() { # path to a .zip or .dmg
  local out id status
  out=$(xcrun notarytool submit "$1" --keychain-profile notary-profile --wait 2>&1)
  status=$(echo "$out" | awk '/^ *status:/{s=$2} END{print s}')
  if [ "$status" != "Accepted" ]; then
    id=$(echo "$out" | awk '/^ *id:/{print $2; exit}')
    echo "ERROR: notarization of $(basename "$1") was not accepted." >&2
    [ -n "$id" ] && xcrun notarytool log "$id" --keychain-profile notary-profile >&2
    exit 1
  fi
}

ditto -c -k --keepParent build/export/MyApp.app build/MyApp-notarize.zip
notarize build/MyApp-notarize.zip

--wait keeps the command running until the service finishes, so the script can move on to stapling. The function reads the status: line and stops on anything other than Accepted. When a submission fails, notarytool log prints the service’s JSON report, which names each file that failed and why, for example a nested binary with no secure timestamp.

Staple The App Before It Goes In The DMG#

Notarization creates a ticket on Apple’s side. Without a stapled ticket, Gatekeeper has to look it up online the first time someone opens your app. Stapling attaches the ticket to the file so the check works with no network.

Each stapled file only carries its own ticket. The DMG’s ticket stays with the DMG. When someone drags the app to Applications, the copy is still quarantined, Gatekeeper checks it again at first launch, and it needs a ticket of its own. So the app gets stapled first, while it’s still a plain folder on disk:

Shell
xcrun stapler staple build/export/MyApp.app
xcrun stapler validate build/export/MyApp.app

The zip you uploaded is only a container for the upload. stapler lists the formats it supports as “UDIF disk images, code-signed executable bundles, and signed ‘flat’ installer packages”, so the ticket goes on the .app and the zip gets thrown away. In a bundle the ticket lands in Contents/CodeResources, which is why stapling has to happen before the app goes into a compressed, read-only DMG.

Then The DMG Gets The Same Treatment#

The DMG is the first thing Gatekeeper checks after a download, so it needs its own signature, notarization and ticket. A disk image isn’t executable code, so my scripts sign it with --timestamp and no runtime flag:

Shell
mkdir build/dmg-src && ditto build/export/MyApp.app build/dmg-src/MyApp.app
create-dmg --volname MyApp --app-drop-link 450 190 build/MyApp.dmg build/dmg-src
codesign --force --timestamp --sign "$IDENTITY" build/MyApp.dmg
notarize build/MyApp.dmg
xcrun stapler staple build/MyApp.dmg

Stapling writes into the DMG, so anything that hashes or signs the final file has to come after it. My scripts generate Sparkle’s EdDSA signature for the appcast right after stapling, from the finished DMG.

Checking What People Download#

My release scripts end with checks that ask the same questions Gatekeeper will. For the app:

Shell
spctl -a -vvv -t exec MyApp.app
xcrun stapler validate MyApp.app
codesign --verify --deep --strict --verbose=2 MyApp.app
codesign -dvv MyApp.app 2>&1 | grep -E "flags|Timestamp|Notarization"

A good result says accepted with source=Notarized Developer ID, then “The validate action worked!”, then valid on disk and satisfies its Designated Requirement. Here --deep checks every nested piece in full, where the default is only a shallow pass. The last line should show flags=0x10000(runtime), a Timestamp= line and Notarization Ticket=stapled.

The DMG needs a different assessment type. The default execute type rejects it with “the code is valid but does not seem to be an app”. -t open on its own comes back rejected with source=Insufficient Context. --context isn’t in man spctl, but this is the form that comes back accepted:

Shell
spctl -a -vvv -t open --context context:primary-signature MyApp.dmg
xcrun stapler validate MyApp.dmg

On my Mac, -t install also accepts a signed DMG, though man spctl describes that type as the one for installer packages, so my scripts stick to -t open. An unsigned DMG fails both with source=no usable signature.

Last, check the app as the buyer gets it, from inside the mounted DMG:

Shell
hdiutil attach -nobrowse -quiet build/MyApp.dmg
xcrun stapler validate /Volumes/MyApp/MyApp.app
spctl -a -vvv /Volumes/MyApp/MyApp.app
hdiutil detach /Volumes/MyApp -quiet

If the app was stapled before packaging, stapler validate passes here too. If it fails, the app in the DMG has no ticket of its own, and Gatekeeper can’t confirm it was notarized when someone opens it for the first time without a network connection.