Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

The Roads Not Taken: Hardened Runtime, Notarization, Sandbox

~16 min · bundle-signing, hardened-runtime, notarization, app-sandbox, library-validation

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Hardened runtime buys nothing without notarization."

Three Switches Built for Someone Else's Mac

Apple's distribution road for a Mac app outside the App Store has three parts. A Developer ID Application certificate signs it. The hardened runtime (codesign --options runtime) is switched on. And the app is notarized: uploaded to Apple's notary service, scanned, and given a ticket that Gatekeeper checks on a stranger's Mac. The Mac App Store adds a fourth, the App Sandbox. Every one of these exists so that a person who did not build the app can trust it. The family's Mac apps are run by the person who built them, on Macs that already hold the signing certificate. That is why the family leaves these roads alone — and why you should know exactly what each one does before a template turns it on for you.

What the Hardened Runtime Actually Changes

Measure it instead of repeating it. Sign the same binary with the self-signed identity twice, once with --options runtime. The CodeDirectory flags differ (0x0(none) against 0x10000(runtime)) and so does the cdhash. The designated requirement does not: both read identifier "…" and certificate leaf = H"…", character for character. A family doc once said the flag changes the requirement; for a certificate-signed app it does not, and a claim like that is exactly what the measurement is for.

The real cost is library validation, which the hardened runtime turns on. A hardened process may only map libraries signed by Apple or by the same Team ID. A self-signed certificate has no Team ID (TeamIdentifier=not set), so a hardened app refuses a dylib signed with its own certificate: "mapping process and mapped file (non-platform) have different Team IDs". The com.apple.security.cs.disable-library-validation entitlement lets it load again, which leaves you with a hardened runtime whose main protection you have just switched off. The runtime also blocks DYLD_* environment variables, unsigned executable memory and debugger attachment unless you add more exception entitlements.

The Ruling, and the Two Apps That Show It

The family default is off. A repository that turns it on says why, and its build gate asserts the state either way. The coding engine's client is hardened with no exception entitlements: it loads no foreign libraries, and its engine is a separate child process with its own signature, which a hardened parent can launch. The video player bundles and signs a pinned libmpv dependency closure, so its doctor script fails if the runtime flag is present. Opposite settings, one rule: the signature's state is checked, never assumed.

Notarization and the Sandbox

Notarization requires the Developer ID certificate, the hardened runtime and a secure timestamp (the family's local scripts pass --timestamp=none, so none of their apps could be notarized as signed). The App Sandbox is inherited by every child process, which rules it out for an app that launches an interpreter into a user-chosen repository, and it requires a user-granted file access for paths the app did not pick, which rules it out for a player that opens a path handed over by another app. The day one of these apps leaves the fleet, the road is already paid for: the Developer Program membership includes Developer ID. On iOS there is no road not taken. Every app is sandboxed, and that is where the iOS track begins.

Code

Measure the hardened runtime: flags change, the requirement does not, a self-signed dylib is refused·bash
clang -dynamiclib lib.c -o libspark.dylib && clang main.c -o probe && cp probe probe-rt
codesign --force --sign "Spark Local Signing" libspark.dylib
codesign --force --sign "Spark Local Signing" --identifier com.example.probe probe
codesign --force --sign "Spark Local Signing" --identifier com.example.probe --options runtime probe-rt

codesign -dv probe    2>&1 | grep CodeDirectory   # flags=0x0(none)
codesign -dv probe-rt 2>&1 | grep CodeDirectory   # flags=0x10000(runtime)
diff <(codesign -d -r- probe 2>&1 | grep designated) \
     <(codesign -d -r- probe-rt 2>&1 | grep designated) && echo "same requirement"
codesign -dv probe-rt 2>&1 | grep TeamIdentifier  # TeamIdentifier=not set

# Absolute paths: a hardened program refuses a relative dlopen path before library validation runs.
./probe    "$PWD/libspark.dylib"   # loaded, answer=42
./probe-rt "$PWD/libspark.dylib"   # dlopen failed: ... have different Team IDs
The road for the day an app leaves the fleet (Developer ID, not the self-signed identity)·bash
# Sign nested code first, then the app, with the runtime and a secure timestamp:
codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: Example Name (EXAMPLE_TEAM)" Spark.app
ditto -c -k --keepParent Spark.app Spark.zip

# Store App Store Connect API key credentials once, in the Keychain:
xcrun notarytool store-credentials spark-notary \
  --key AuthKey_EXAMPLEKEY.p8 --key-id EXAMPLEKEY --issuer 00000000-0000-0000-0000-000000000000
xcrun notarytool submit Spark.zip --keychain-profile spark-notary --wait

xcrun stapler staple Spark.app                  # attach the ticket for offline checks
spctl --assess --type execute -vv Spark.app     # accepted, source=Notarized Developer ID

External links

Exercise

Reproduce the measurement: build the tiny probe executable that dlopens a one-function dylib, sign both with your self-signed identity, and sign a second copy of the probe with --options runtime. Record the CodeDirectory flags and designated requirement of each, then run both against the dylib and copy the exact error. Finally, add a check to Spark's build script that reads codesign -dv and fails if the runtime flag appears, so the family default is asserted rather than assumed.
Hint
main.c needs only dlopen, dlerror and dlsym from <dlfcn.h>. For the build check, codesign -dv prints the flags on the line that starts with CodeDirectory; match runtime there and nowhere else. Pass the dylib as an absolute path. Given ./libspark.dylib, the hardened probe stops at "relative path not allowed in hardened program" and never reaches the Team ID check.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.