A Mac That Trusts the App Before It Opens
Two ceremonies people constantly conflate β a Developer-ID signature that makes a build tamper-evident, and Apple notarization that staples a verdict macOS checks at open β and the inside-out signing the rest of the toolchain fights you on
You can build a beautiful application and still ship a terrible first impression. On macOS, a build that the operating system does not recognize gets met with a blunt alarm β "cannot be opened because it is from an unidentified developer," or, on a build that was merely copied between machines, the worse and more confusing "this app is damaged and can't be opened." For a project whose entire premise is trust you can verify, opening with a trust alarm is precisely the wrong note.
So once the Mac app existed, we did the invisible second half of shipping it: we taught macOS to vouch for the app before it ever opens. The interesting part is not that we did it. The interesting part is that "sign and notarize a Mac app" is two completely different operations that almost everyone β including us, at first β treats as one, and that the standard tool for the job lies to you about having done it.
Two ceremonies, not one
Here is the distinction the whole effort turns on, because getting it wrong is the difference between an app that opens and one that doesn't.
Code signing with a Developer ID is a cryptographic seal. It hashes the contents of the bundle and signs that hash with a private key only we hold. The claim it makes is narrow and absolute: this build came from us, and not a single byte has changed since we sealed it. If anything is altered in transit β a corrupted download, a tampered binary, a well-meaning tool that rewrites a file β the hash no longer matches and the signature is void. This is not branding. It is tamper-evidence, the same property the cryptography enforces everywhere else in the system: you do not take our word for it, you check the math. A changed byte breaks the seal, and a broken seal is supposed to stop the app cold.
Notarization is a verdict, not a seal. Signing proves who made it; notarization is Apple scanning what we made. We upload the signed build to Apple's notary service, it runs the bundle through automated malware checks, and if it passes, Apple issues a ticket. That ticket gets stapled onto the bundle, and when a user double-clicks, macOS reads the staple and opens the app cleanly β no warning, no right-click-to-override dance.
They are independent. A build can be signed and not notarized (the app proves its
origin, but a fresh Mac still distrusts it on first contact). It can be neither
(the "unidentified developer" wall). What a stranger's Mac wants, to open your
app on sight with no ceremony, is both β a valid Developer-ID signature and
a stapled notarization ticket. We wired both into one command,
naoms dist mac --notarize, with plain ad-hoc signing left as the default for
local builds that never leave the machine.
flowchart TD
A[Assembled .app bundle] --> B{Sign every nested
Mach-O, inside-out}
B -->|Developer ID + hardened runtime
+ secure timestamp| C[Tamper-evident bundle
changed byte -> seal voids]
C --> D[Zip via ditto -> submit to
Apple notary -> wait for verdict]
D -->|rejected| E[Build fails loudly
nothing ships]
D -->|approved| F[Staple ticket INTO bundle]
F --> G[Wrap in .dmg -> sign + notarize
+ staple the image too]
G --> H[Stranger's Mac opens it on sight
online OR offline]
The lie inside codesign --deep
The naΓ―ve way to sign a Mac app is codesign --deep, which promises to walk the
bundle and sign everything inside it. It does not. Apple's own tooling treats
anything living under Contents/Resources/ as a resource β data to be hashed,
not code to be signed. And our entire runtime payload lives under
Contents/Resources/: the Deno binary, the FROST signer, the Rust core dylib,
the Node native addons. --deep walked right past every one of them and reported
success, leaving the executables that actually run unsigned. The notary service
is not fooled; it rejects the bundle with errors like "the executable does not
have the hardened runtime enabled," and you are left debugging a green-looking
build that Apple quietly refuses.
So we sign inside-out, by hand: collect every nested Mach-O binary, sign each
one explicitly with a hardened runtime and a secure timestamp, and only then sign
the outer app. The catch is finding them. Native addons ship with no helpful file
extension β esbuild, naoms-signer, and friends are just executables with no
suffix β so we cannot detect them by name. We detect them the way the operating
system does: by reading the first four bytes of every file and matching the
Mach-O magic numbers (0xFEEDFACE and 0xFEEDFACF for 32- and 64-bit,
0xCAFEBABE for a universal binary, plus their byte-swapped twins).
That last magic number is a small trap worth naming, because it is the kind of
detail that costs an afternoon. 0xCAFEBABE is the magic for a macOS universal
binary β and it is also the magic for a Java .class file. A bundle that
happens to carry any compiled Java would have those files mis-detected as
executables and fed to codesign, which fails on them. So the collector skips
.class files (and symlinks) explicitly. The honest version of "detect binaries
by content, not by name" has to know that two unrelated formats picked the same
four bytes decades apart.
Why a JIT runtime makes signing harder
Apple's hardened runtime is mandatory for notarization, and it exists to lock down exactly the things a malicious program abuses: writing then executing memory, loading unsigned libraries, inheriting environment variables that redirect dynamic linking. The problem is that those are also the things a legit JavaScript engine does on purpose. Deno's JIT compiles code to memory and then runs it β which is precisely "write memory, execute memory," the behavior the hardened runtime blocks by default.
So a Developer-ID-signed, hardened-runtime daemon, with no further care, would sign cleanly and then crash on its first line of real work. The fix is a narrow set of entitlements granted only to the bundled runtime β allow-jit, allow-unsigned-executable-memory, the dyld environment-variable allowance, and disable-library-validation β each one a small, deliberate hole punched in the hardened runtime for a known-good reason. Signing was never the hard part. Making a signed, sandboxed runtime still able to run was.
There is a second-order version of the same fight. The daemon, on boot, likes to
write a small hash sidecar next to its dylib and reconcile its node_modules
folder. Both are writes into the bundle β and the bundle is now a read-only,
cryptographically sealed object. The first write would break the signature; the
second fails outright with a permission error because a notarized bundle is not
writable. So the launcher boots the daemon with the sidecar write disabled and
the module directory pinned to a fixed manual layout. Sealing the app means every
"just write a little file next to myself" habit the code had now has to be found
and switched off, or the seal you worked for dissolves on first launch.
"It boots fine" can be a lie the build box tells you
The first notarized build installed and launched on a clean M1 β and then the
daemon died on boot. The Rust core dylib links against OpenMP (libomp, pulled
in by the on-device speech feature), and it linked it by absolute Homebrew
path: /opt/homebrew/opt/libomp/lib/libomp.dylib. On the build machine that
path exists, so the dynamic loader finds the library and everything works. On a
clean Mac that never installed Homebrew, that path is empty. The load fails, the
cryptography self-test fails behind it, and the daemon exits with a fatal error
before it can serve a single page.
The line in the commit log is the one worth keeping: "the earlier 'boots fine' was on a build box that happened to have libomp." A successful launch told us nothing, because the build host was quietly lending the app a dependency the app did not actually carry. This is a Wholeness failure in the precise project sense β a thing that is not complete in itself, leaning on its environment without admitting it.
The fix is to make the bundle genuinely self-contained. At build time we run
otool -L on the staged dylib to read its actual dependency list, copy every
non-system library it names into the bundle, and rewrite the link with
install_name_tool to point at @loader_path β a relative reference that means
"next to me," wherever "me" ends up. Repathing invalidates the signature, so
this has to happen before the codesign pass, not after. The same class of fix
covers the cryptography WebAssembly module, which was being assumed-present
rather than built and bundled.
And then the part that matters most for a project that takes honesty as an axiom: we do not merely fix it, we make it impossible to ship un-fixed. A fail-closed check re-parses the staged dylib and throws if any absolute non-system path survives β the kind of dependency that would crash on a clean Mac. A build that is not self-contained does not produce a warning. It does not produce a build. The proof is mechanical, not a promise: the re-signed dylib was loaded on a machine with no Homebrew at all and reported back a clean load, and the whole bundle was launched over SSH on a stranger's clean M1, where the daemon booted and served the onboarding screen.
Stapling for a Mac that is offline
There is one more subtlety in where the notarization verdict lives. By default, macOS can check a notarization verdict by phoning Apple at open time. But the whole point of this app is that it works without depending on someone else's server, including on first launch with no network. An online-only check would quietly re-introduce the "is damaged" failure for an offline user β the very thing we set out to kill.
So we staple: the notary ticket is fetched at build time and written into the
bundle itself, so macOS can validate it locally, offline, forever. The mechanics
are fiddly β the notary service only accepts a single archive, so the bundle is
zipped to submit, the verdict is awaited (a rejection exits non-zero and fails
the build loudly, rather than shipping an un-notarized image), then the ticket is
stapled onto the live bundle and re-validated. We staple both the app and the
disk image that carries it, because Gatekeeper assesses the .dmg when it is
opened and the .app when it is launched. Two different moments, two different
things to vouch for.
Honestly scoped
This is the macOS path, and only the macOS path: Developer-ID signed, inside-out
across every nested binary, Apple-notarized, stapled for offline launch,
self-contained against a clean machine, behind one command. The default build is
still ad-hoc β real notarization is opt-in behind --notarize, because it
requires Apple credentials and a few minutes of round-trip we do not impose on a
local-only build. We make no equivalent claim yet for Windows or Linux; those
platforms have their own trust ceremonies, and they are ahead of us, not behind.
What we did prove is the principle on the platform we drove first. The seal is tamper-evident: change a byte and it voids, which is the honesty axiom applied to the very first moment of contact, before the app has rendered a single pixel. The verdict is real: Apple scanned it, and a stranger's Mac can confirm that locally without trusting us or a network. And the bundle is whole: it carries what it needs and refuses to ship if it doesn't. The trust starts before the app opens β which, for a system whose entire argument is you should not have to take our word for it, is exactly where it belongs.
Written by AI agents from real project logs; owned and edited by Mujo.