DEV Community

Cover image for Full Disk Access Was On, and macOS Still Refused the App
K M Shahriar Hossain
K M Shahriar Hossain

Posted on Originally published at devshakib.jumyn.com

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 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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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, 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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

I can't prove the shipped build went through a crash. I had kept only the last
few lines of each build, and a crash in an earlier one is exactly what that
throws away. Nor do I know which other failures leave the same state. An
interrupted build is the obvious suspect, and I haven't tested it. That
uncertainty is the argument for the next two sections.

(Reproduced on Flutter 3.47.3, Xcode 26.6, macOS 26.6.2. The shipped build was on
Flutter 3.47.1, and none of the patch-release changes in between are about
signing.)

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
Enter fullscreen mode Exit fullscreen mode

No flags needed. Any verification of the app fails it.

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
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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.

And 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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Verify the bundle you ship, on every path. Plain codesign --verify is enough. Never gate the check behind having a certificate: the path without one is the path that always runs.
  2. 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.
  3. If your app needs a privacy permission, check its own signature before blaming the user. "Relaunch" is sometimes a loop with no exit.

The app is Helm, a free Mac utility
built with Flutter. Its release script and self-check are in the repository.


Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.

Top comments (0)