Full Disk Access Was On, and macOS Still Refused the App
Full Disk Access Was On, and macOS Still Refused the App
A Mac app I maintain needs Full Disk Access. It measures disk usage, and most of what is worth measuring sits behind that permission. I switched it on in System Settings ▸ Privacy & Security ▸ Full Disk Access, and the app went on reporting limited access. Its banner said what these banners say: already granted? Relaunch. Relaunching changed nothing, and never could have.
The Problem
The app did not validate A permission is granted to an app, and to macOS "the app" is not a name. So the first question is whether macOS can tell what the app is:
$ codesign --verify --deep --strict --verbose=2 /Applications/Helm.app
/Applications/Helm.app: nested code is modified or invalid
file modified: /Applications/Helm.app/Contents/Frameworks/App.framework
App.framework is where Flutter puts the compiled Dart. Verified on its own it was fine, and so were the other four frameworks in the bundle. What failed was the app around them.
The Root Cause
An app's signature seals its contents. Contents/_CodeSignature/CodeResources records, for every nested framework, the hash of the code that should be there. It recorded one App.framework, and the bundle held another:
seal records
Frameworks/App.framework 4ba5cc60… actually there
Frameworks/App.framework c7a45621… Contents/_CodeSignature/CodeResources
15:08:47 Contents/MacOS/Helm
15:08:47 Contents/Frameworks/App.framework/…/App
15:30:38
The seal and the executable came from one build. The framework came from a build twenty-two minutes later, which put a new App.framework in place and never re-sealed the app around it.
Why a Stale Seal Turns the Switch into Decoration
TCC, the part of macOS behind every Privacy & Security switch, does not store "this app is allowed". It stores a code requirement and checks the running app against it. For an app signed with a Developer ID, that requirement names the developer's team. For an ad-hoc signature, which is what you have without one, it is effectively the hash of the code. A bundle whose nested code does not match its own seal does not validate, and code that does not validate cannot satisfy a requirement. The grant attaches to nothing. The switch reads ON, every protected path stays refused, and no relaunch touches any of it.
Re-signing the Bundle and Granting Access Again
Re-signing the bundle and granting access again fixed it: the app reported Full Disk Access on, and kept it through a restart.
How the Seal Went Stale
Two things write a Flutter macOS bundle. A script phase runs macos_assemble.sh embed, which hands off to Flutter's xcode_backend.dart: it copies the freshly compiled App.framework into Contents/Frameworks and signs it. Then Xcode signs the app, which writes the seal. In a good build the timestamps land a second apart, framework first:
build 1 clean verify ok seal 23:19:32 App.framework 23:19:31
build 2 one Dart change verify ok seal 23:20:06 App.framework 23:20:05
So it is not simply what an incremental build does, which is what I assumed at first and wrote into a changelog. A clean build seals correctly, and so does a Dart-only rebuild straight after it.
What Reproduced It
What reproduced it, once, was a crash. On a copy of the project, a Swift change built fine. The next build, a one-line Dart change, died:
error: unexpected service error: The Xcode build system has crashed.
Build again to continue. Doing as told, with another Dart-only change:
✓ Built build/macos/Build/Products/Release/Helm.app (47.1MB)
$ codesign --verify --strict Helm.app
Helm.app: nested code is modified or invalid
seal records App.framework dda6dabc96dd actual afaf15310e15 Contents/_CodeSignature/CodeResources
23:22:42 Contents/MacOS/Helm
23:22:42 Contents/Frameworks/App.framework/…/App
23:23:28
The build after the crash ran Flutter's embed step, skipped the app's signing, and reported success. Same fingerprint as the build that shipped: seal and executable from one build, framework from a later one.
Why Nothing Caught It
Not because the check is subtle. My first theory was that only --deep compares nested code with the outer seal, so a check without it would have waved this bundle through. It was wrong. On a copy with App.framework swapped out:
codesign --verify App.framework
exit 0 valid on its own
codesign --verify Helm.app
exit 1 nested code is modified or invalid
SecStaticCodeCheckValidity, flags 0-67021
nested code is modified or invalid
No flags needed. Any verification of the app fails it.
The Release Script
The release script did not verify at all. Its signing and its verification lived in the same branch:
if [[ -n "$SIGN_ID" ]]; then
# a Developer ID certificate was found
# ...sign every framework, then the app...
codesign --verify --strict "$BUILD_APP"
fi
The app is free and deliberately has no $99-a-year Developer ID, so that branch never ran. The path that always ran signed nothing and checked nothing, and the DMG shipped whatever flutter build had left behind.
The Fix
Sign and verify on every path, and let a failed verification fail the release:
if [[ -n "$SIGN_ID" ]]; then
SIGN=(--force --timestamp --options runtime --sign "$SIGN_ID")
else
SIGN=(--force --sign -)
# ad-hoc: never notarised, so no runtime or timestamp
fi
# Inside-out: nested code first, then the bundle that holds it.
for fw in "$APP"/Contents/Frameworks/* .framework; do
codesign "${SIGN[@]}" "$fw"
done
codesign "${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "$APP"
codesign --verify --deep --strict "$APP" || {
echo "signature broken" >&2
exit 1
}
Sign with a loop rather than codesign --deep, which Apple advises against for signing. For verifying, --deep is fine, and with --verbose it names the file that changed.
Self-Checking the App
The app now checks itself before telling anyone to relaunch. This goes through the Security framework rather than running /usr/bin/codesign, which does ship with macOS, because it is a yes-or-no question with a status code for an answer:
import Security
func bundleValidates() -> Bool {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(Bundle.main.bundleURL as CFURL, [], &code) == errSecSuccess,
let code else {
return true
}
// couldn't ask: don't accuse
let flags = SecCSFlags(rawValue: kSecCSCheckNestedCode | kSecCSStrictValidate)
return SecStaticCodeCheckValidity(code, flags, nil) == errSecSuccess
}
When access reads as denied and that returns false, the banner stops saying "relaunch" and says the true thing: this copy cannot hold the permission, so reinstall it and grant access again. When the check cannot run at all, it answers true. Telling someone a working app is broken is worse than saying nothing.
The Part That Is Not a Bug
An ad-hoc requirement is the hash of the code, so to TCC every build is a different app. Every update loses Full Disk Access and needs granting again. No release script fixes that. It is the cost of not having a Developer ID, whose requirement names the team instead of the bytes and so survives an update. For a free app, the honest move is to say so at the top of the release notes rather than let people find out.
Three Rules
- Verify the bundle you ship, on every path. Plain
codesign --verifyis enough. - Never gate the check behind having a certificate: the path without one is the path that always runs.
- Don't trust ✓ Built after a build that crashed. Build clean, or verify. The build after a crash can succeed at everything except sealing the app. If your app needs a privacy permission, check its own signature before blaming the user. "Relaunch" is sometimes a loop with no exit.
Comments
No comments yet. Start the discussion.