Apple's Code-signing Quagmire
My quixotic quest to defeat the scourge of code-signing once and for all
This week, I want to cut straight to the heart of the raison d'ĂȘtre for jDeploy: Appleâs code-signing rules.
In a previous article, I waxed nostalgic about the golden age of Java on the Mac, when you could package your Java app inside a native .app bundle and distribute it to your users with no strings attached. No code-signing required. No notarization. No bulky JVM dependency (because OS X included it standard). You could just build the app and send it to your users, and they could run it, none the wiser that the app was written in Java. Ahhh, those were the daysâŠ. before the dark times, before the app store!
First they came for my âbundle sizeâ. In 2011, they deprecated Java on the Mac, forcing me to embed the Java runtime environment into my app bundle, ballooning my app-size from 3 megs, to 70 megs. No big deal, I suppose, as this curse coincided with the blessing of being allowed to sell my apps in the app store.
Next, in 2012, they came for my developer âsovereigntyâ, requiring me to sign my apps using an Apple developer certificate. As of OS X Lion (10.7.3), users of âunsignedâ apps would be met with a scary warning that the app was from an âunidentified developerâ the first time they tried to open the app. Initially they could still choose to open it despite the warning, but in later iterations, they restricted this further. First they removed the option to âRun anywaysâ in the warning dialog. Users had to âRight clickâ the app icon and choose âOpenâ in order to see the âRun Anywaysâ option.
In more recent version or Mac OS, they have added the requirement to ânotarizeâ your apps in addition to code-signing. This process involves sending your app to Apple so they can âinspect itâ. If they donât detect any âmalwareâ, theyâll provide you with a manifest that you can âstapleâ to your app bundle.
It is my experience that every new release of Mac OS has made it more difficult to build apps that can be distributed to my users. As of this writing (2022), developers must sign and notarize their apps or users wonât be able to run them at all. Theyâll get a message saying something to the effect of âThis app is damaged. You should move it to the trashâ, when they try to run your app for the first time.
I have written several shell scripts, ANT scripts, and more recently, GitHub action workflows to automate the signing and notarization process for my apps, but the goal posts keep moving. With nearly every new Mac release, I find that my automation scripts no longer work. Apple also loves to dictate that you use their latest OS and Xcode tools for performing builds, so Iâm always having to adapt my scripts to accommodate them.
So, presently, if I want to create a little utility app in Java that I distribute to my users I need to:
Build the app (usually as an executable jar file).
Bundle the app as a native package (e.g., .app, .dmg, .pkg). Requires a Mac running a recent version of Mac OS, and a tool like javapackager.
Code-sign the native bundle using my apple developer certificate. Requires an Apple developer account (99 USD/year), and almost always requires me to futz with my certificates, revoking old ones, regenerating new ones, re-downloading Appleâs root CA certificates.
Notarize the signed app bundle. Also requires an Apple Developer account, and almost requires me to futz with the command-line options of their tools that make this happen. To be confident of success, you need to run this on the latest Mac OS with the latest Xcode.
Rinse and repeat each time I update the app.
There are so many things to hate about this process, and it is sufficiently cumbersome that I only do this for âvery importantâ apps that have commercial viability.
The idea for jDeploy was born when I decided that there had to be a better way. On the back of my mental napkin, I jotted down a few characteristics that I wanted my âdesktop appâ deployment process to have. They included:
Cross-platform. You shouldnât need a Mac to build for Mac, or a Windows to build for Windows. You should be able to build your app bundle for all platforms (Mac, Windows and Linux) on any platform. E.g., you should be able to build your Mac app on Linux, if thatâs what you use for development.
Auto-updates. Deployed apps should be able to automatically download updates so that users donât need to keep downloading your latest version. (99% of support responses begin with the question: âwhat version are you using?â)
No bundled JVM. Apps shouldnât need to embed the JVM in the app bundle. The launcher should be smart enough to download an appropriate JVM as needed. This way if you deploy multiple apps with compatible JVM requirements, they can all share the same JVM.
No code-signing required. The code-signing process is the most painful part of app distribution, and I donât think it would be possible to achieve the cross-platform requirement unless I found a way to work around code-signing.
Apps should feel like first-class desktop apps. The app should integrate properly with the native system. There shouldnât be any âcluesâ to the user that it was written in Java or packaged in a special tool.
The Launcher
The key to bundling a Java app as a native app is to create a native launcher. This is a tiny native program that sets up the JVM and runs the Java app. On Windows and Linux, this launcher is the executable that users âdouble-clickâ. On Mac, apps are actually directories with the â.appâ extension, and include a particular file structure. The launcher is typically placed inside the Contents/MacOS directory of the bundle. When a user double-clicks the app, the system looks up the launcher name in the bundleâs Info.plist file, and runs it.
The launcher was the key to solving most of the problems I had with Java app distribution. Most existing launchers just load the JVM and run the app. The jDeploy launcher would first download app updates, and install an appropriate JVM (if it couldnât find one already installed), before loading the JVM and running the app. This would solve the auto-update and no bundled JVM requirements.
In order to ensure a cross-platform build process, the launcher would be pre-compiled and included as part of jDeploy. This implies that every app built with jDeploy would use the same launcher binary. The launcher would load some XML meta-data to discover which app it needed to load. On Mac, this meta-data would be included as a file inside the app bundle. On Windows and Linux, since the launcher was a single binary file, the meta-data would be appended to end of the binary - and the launcher would know how to extract it at runtime.
This strategy (using the same launcher binary in each app) allowed me to write cross-platform build workflows for native apps that didnât require the use of any third-party tools. On Mac, for example, the workflow constructs the .app bundle by creating the correct structure of text files and directories, then just copies the pre-compiled binary into the Contents/MacOS directory of the app bundle.
Slaying the Code-sign Dragon
Suffice to say that I was pretty satisfied with myself the first time I built a native Mac bundle with jDeploy that actually launched. I felt immense gratification when, after double-clicking my app, it popped up a progress window indicating that it was downloading app updates, then downloading the java runtime environment. Actually, Iâm taking some artistic license in describing that scene, as it never really happened that way. The road to âsuccessâ was iterative, and full of set-backs. There were many gratifying moments where I witnessed that âUpdateâ dialog working properly, but I canât honestly think of any particular moment as the moment. That scene, therefore, is a fictional aggregation of many such moments.
While that successful launch was a triumph, for sure, there was still an elephant lurking in the room, and it couldnât be ignored forever. In order for my app bundle to be useful, I needed to be able to distribute it, but Appleâs code-signing rules made that difficult. If I zipped up the app and copied it to my web server, then downloaded it on a different Mac, it would complain that the app wasnât signed. And on anything newer than Catalina (I think Catalina was the beginning of strict compliance, though it might have been Mojave), it wouldnât let me run the app at all. This is because, when you download an app from the internet using your web browser, it will set a âQuarantineâ flag on the app bundle, which tells GateKeeper (Macâs security agent) to run some security checks on the app the first time it is run, and to prevent it from running at all if it isnât signed.
Adding Code-signing to the Build Process
In order to move forward, I temporarily compromised on my values, and added support for âcode-signingâ as a part of the jDeploy build process. This, of course, only worked when I was building the app on a Mac with Xcodeâs build tools installed. Unfortunately, this wasnât enough to satisfy even my own use-cases, despite the fact that I use a Mac. My main workhorse is a 2012 Mac Pro, and at the time I added the code-signing feature, I was limited to running 10.13 (High Sierra), and, for some reason, the apps signed on High Sierra still wouldnât run on the latest versions. I needed to do the code-sign step on a newer Mac. Ugh!!
So I went further down the rabbit hole of compromised values, and built a âCode-sign daemonâ mode for jDeploy that allowed me to delegate the âcode-signingâ to a different machine, using a âclient-serverâ model. I made it so that I could run jDeploy in âserver modeâ. When running in server mode, it could receive requests from jDeploy clients to code-sign an app bundle. This allowed me to run jDeploy on my older version of Mac OS, and have the code-signing performed by my app running a newer version of Mac OS. Technically this also solved the âcross-platformâ requirement, sort of, since client mode would work on Windows and Linux as well.
Wiping out the stain of Code-signing
A smarter man might have taken this âwinâ and called it a day, but I still wasnât satisfied with the process. While you could technically now build a Mac app on Windows or Linux, you still needed to have a Mac on your network to do the actual code-signing. So you still had to jump through all of Appleâs code-signing hoops, and in some ways having to set up a separate machine made the process more complicated, not less.
The mere act of code-signing was a stain on the entire process. I would not be satisfied until I had found a way to either skip the process entirely, or to âautomateâ it away. As far as I know, there is no way to completely âautomateâ it away. If youâre going to participate in the code-sign circus, you will need to at least occasionally log into your Apple developer account, sign some agreements, and generate new certificates. There was no way around that. Perhaps this quixotic quest was doomed to fail.
Inspired by Chrome
When Iâm stuck, I like to look to prior art for inspiration. Who else has faced a similar challenge before, and how did they solve it? While engaging in such a thought exercise, I recalled that Chrome allowed you to save a PWA (Portable Web App) as a sort of native app, that you could double-click, and it would open the PWA in Chrome. I doubted that Chrome signed these apps, but wasnât sure. To I peeked inside one of them to see what was going on. As I suspected, it wasnât signed. But if these app bundles werenât signed, I wondered, how did they by-pass GateKeeper?
Thatâs when the light went on for me. The âQuarantineâ flag is applied to an app when it is downloaded. If the app is never downloaded it doesnât get quarantined. In the spirit of completeness, I tested various archive solutions (tar, zip, etcâŠ) to see if they were somehow immune to the quarantine flag, but, as you would expect, they arenât. If you download a Zip file, it will flag the zip, and when you extract it, the flag gets inherited by the extracted files. So the solution might be to avoid downloading the app, somehow. But how? In Chromeâs case, it avoided signing its PWA bundles because it constructed those bundles directly - it didnât download them. Perhaps I could use the same strategy here.
Following this insight, I created an âinstallerâ app that would know how to construct the native bundle on the fly. Because the app, then, wouldnât be downloaded, it wouldnât have the quarantine flag applied to it, and thus, it could run without being signed. It turns out that using an installer had some additional benefits, such as the ability to automatically add aliases to the Dock and Desktop. It also provided the user with an opportunity to customize their auto-update preferences. However, it didnât quite solve the code-sign problem. It just kicked the can down the road a little bit.
So, I now had a workable solution that would allow me to not code-sign the app bundle itself, but I had a new problem: the installer needed to be code-signed. At first, I feared that I was effectively no further ahead. After all if Iâm going to have to code-sign the installer, I might as well have just code-signed the app and be done with it.
Sign Once Run Anywhere
Then I had another thought. I had already managed to solve some of the cross-platform issues by pre-compiling the launcher and including the same launcher in every app. What if I did the same with the installer? I.e., what if every app used the same installer? Then I could âpre-sign-and-notarizeâ the installer bundle and include it with jDeploy. Because the installer was pre-signed, I wouldnât be able to change it in any way on a per-app basis. That might be a show-stopper, unless I could find a way for the installer to load some meta-data that could be used to customize the app.
In the worst case, I could just prompt the user to enter some sort of code or URL into the installer, to load the correct app configuration. But Iâd reserve that for âplan Zâ. I preferred to find something seamless that didnât involve prompting the user.
My first idea was to package the installer inside an archive (e.g., .tar or .zip), but include a hidden directory with some meta-data. The installer could then look for this hidden directory when it runs, and load the meta-data seamlessly behind the scenes. Initial tests seemed to work, but when I tried this on Catalina, I found that it wasnât able to find this hidden directory for some reason. I discovered that, when you run a âquarantinedâ app, it will actually make a copy of the app bundle into a temporary directory, and run that copy instead. This made it impossible to find the meta-data that I had provided. Clever move, Apple.
The only thing I was able to change on the installer without breaking the integrity of the code signature was the name of the app. That isnât much, but it was all that I needed. jDeploy apps were already deployed in the cloud (on npm), so it was already making HTTP requests to the npm registry to get app information. I just needed to embed some sort of code in the appâs name so that the installer knew which app it was installing. Including some big long npm project name would be ugly, so I decided to embed a short code into the version string, which is part of the installer appâs name, that could be used to look up the app details.
This required a small change to the build process for it to work. I set up a central registry to keep track of these app codes. When jDeploy publishes an app to npm, it will register the app with the jDeploy registry. When the user later runs the installer, it will contact the registry and load the app details. This is all seamless, automated, and cross-platform.
Mission AccomplishedâŠ
So here we are. We now have a simple process for building and distributing Java apps native desktop bundles on Mac, Windows, and Linux. The initial setup is relatively painless, and involves only running the jDeploy GUI to set up things like your app name, version, and icon. Thereafter it can be completely automated. You donât need to futz with Apple developer certificates ever again, if you donât want to. For that matter, you donât need to have a Mac at all. As far as my original goals go, Iâm at âMission Accomplishedâ stage.
Is jDeploy right for every project? No. If you want to distribute your app in the Mac app store, you canât do it the jDeploy way. You need to bundle everything, including the JVM, inside your app bundle, and you need to sign it using an Apple Developer certificate. In some cases, even if youâre not deploying to the app store, you may prefer the more conventional approach of code-signing and notarizing your app. It is a hassle to jump through those hoops, and youâll never get those hours of fighting with Appleâs certificates back, but if the app is a major commercial app, it might still be worth it. But for the rest of the apps - for the simple little utilities you develop to make peoplesâ lives better, or for your own internal development tools, or just about anything else you want to distribute to your desktop users, jDeploy is a good choice.
Share your own stories
The goal of this newsletter is to build a place for Java desktop developers to come together, and talk about ways to make Java more compelling for desktop development. I write a mix of historical articles on Java-related topics, and technical tutorials on things desktop-related. I try to publish a new article weekly. If you want to be notified of new posts as they happen, remember to subscribe.
Also, please share your own code-signing war stories in the comments. Iâm always happy to hear about how code-signing has ruined the lives of other devs. Makes me feel less âaloneâ in the world :)
For not-for-profit entities that maintain open source tools, signing is impossible. To be able to get a signing cert you need to be "listed" and that can't be as an "autonomous workers collective" - you have to have a Lord whose identity can be verified. Not happening. "We don't have a Lord, we have a rotating leader whose decisions are ratified by a bi-weekly meeting with a simply majority...".
So there it has rested for years - we have to explain to users how to bypass Apple's ever more complex system or walls and moats just so they can run an inoffensive, free, CAD tool.