If you’re planning to distribute console applications to Macs, the rules have changed. All new Silicon Macs require applications downloaded from the internet to be both signed and notarized. Last week, we learned how this is implemented and how to
configure the macOS developer environment for notarization. This week, we’ll put those pieces together to notarize a .NET 6 console application. You’ll need the credentials we created last week, as well as a basic .NET console application (such as the basic one created by
dotnet new console). Of course, if you have a real console application, feel free to use that as well!
Why not an earlier .NET version? There were numerous bugs and issues in earlier versions of the runtime, especially for ARM-based Macs. For example, .NET 5 cannot sign single-file executables and lacks native support for Apple Silicon. Many of the critical bugs were resolved in .NET 6 (with more fixes in .NET 7). I strongly recommend using the latest version of the framework for the best experience.
What if your application is written on a different platform? Not a problem! Just ignore everything about building the .NET application. All of the other steps should still apply.
If you don’t already have the Xcode Command Line Tools installed, run
xcode-select --install to install them. You will need these tools to create universal binaries, perform code signing, and handle the notarization process.
Building the application
The actual process of building a macOS console application with .NET 6 is fairly easy. That said, you currently need to avoid enabling
PublishReadyToRun. These two settings have issues which may prevent console applications from running. A simple configuration (from the
.csproj) might look like this:
1<PropertyGroup> 2 <OutputType>Exe</OutputType> 3 <TargetFramework>net6.0</TargetFramework> 4 <PublishTrimmed>true</PublishTrimmed> 5 <PublishReadyToRun>false</PublishReadyToRun> 6 <PublishSingleFile>true</PublishSingleFile> 7 <SelfContained>true</SelfContained> 8 <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> 9 <IncludeSymbolsInSingleFile>false</IncludeSymbolsInSingleFile> 10 <EnableCompressionInSingleFile>false</EnableCompressionInSingleFile> 11 <DebugType>embedded</DebugType> 12 <UseAppHost>true</UseAppHost> 13</PropertyGroup>
We can now compile create a publishable application with
dotnet publish -c Release -r osx-arm64 (for Silicon devices) or
dotnet publish -c Release -r osx-amd64 for the Intel-based Macs. This will create a self-contained executable that runs on the system. All of the code is encapsulated, creating a single, executable file.
Creating Universal binaries
If we build both executables, it is possible to combine those into a ‘universal’ or ‘fat’ binary that will run on both platforms. A universal binary is a special executable format that can contain one or more platform-specific executables. The operating system will automatically pick the most appropriate architecture to run. This is not a type of executable that .NET 6 can natively create for us (although that might change in the future). Instead, we’ll have to create one ourselves.
Apple provides a tool called
lipo for this purpose. I can merge two files to create a universal binary. Assuming that we have two files my-app-arm64 and my-app-amd64, you can use this command to create a new binary called *my-app:
1lipo -create -output my-app path/to/my-app-arm64 path/to/my-app-amd64
The resulting executable will now run on both Intel and ARM based Macs.
Signing the code
One we have an executable, we need to sign it. To do this, we use the
codesign utility. We will need to provide three things for the signing:
The name (or thumbprint) of the certificate. If we provide the name, Codesign will look for a certificate with the matching name. Multiple matching names could be available in some cases. Alternatively, we provide a thumbprint (SHA-1) identifier. This will match a single certificate using it’s unique hash.
The binary we want to sign
An entitlements file. This provides a list of permissions that the application requires the hardened runtime to provide. This can include access to devices or features. It can also be used to relax certain automatic security checks. At a minimum, .NET requires
com.apple.security.cs.allow-jit. Starting with .NET 6.0.1,
com.apple.security.cs.allow-unsigned-executable-memoryis no longer required, but it was mandatory for earlier versions. While older documentation recommends
com.apple.security.cs.disable-library-validation, these are not generally required (and including them can reduce the overall application security). A basic
.plistfor our console application would look like this:
1<?xml version="1.0" encoding="UTF-8"?> 2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3<plist version="1.0"> 4 <dict> 5 <key>com.apple.security.cs.allow-jit</key> 6 <true/> 7 </dict> 8</plist>
To sign the code, we use the following command:
1codesign --force --verbose --timestamp --sign "THUMBPRINT" --options=runtime --entitlements entitlements.plist ./path/to/binary
Let’s understand these settings.
- Overwrite any signatures or settings that already exist
- Provide a slightly more detailed response
- Ensures that a timestamp server is used to guarantee the date/time of the signing. This is a requirement
- The thumbprint or subject common name of the certificate to use for signing. If a SHA-1 thumbprint is used, it should be provided as 40 hexadecimal digits without any spaces. If a name is used and multiple names match, the signing will fail.
- Runtime hardening (“runtime”) is required for notarization. This forces processes to use a hardened runtime environment, with restrictions selectively relaxed by entitlements
- Selectively relaxes security hardening restrictions for the application.
The process of signing the code alters the executable. For console applications, the signature is appended to the binary file. For
.app packages, an internal
_CodeSignature folder is created. For scripts, extended attributes are created which contain the signature details (
com.apple.cs.CodeSignature). It’s worth mentioning that unlike Windows, DLLs are not considered executable code.
Now that the code is signed, it can be notarized.
The notarization process sends the executable to Apple, where it is validated and approved. To send an executable, we first need to compress the file(s) an put them in a ZIP:
1ditto -c --sequesterRsrc -k -V /path/to/files/* application.zip
This creates (
-c) a ZIP (
-k) file using the provided binaries, and creates a separate structured in the ZIP for the macOS specific resources (–sequesterRsrc). No specific name is required for the ZIP file. If we were sending a
.app file, we would also include
--keepParent to ensure that the parent
.app folder was part of the package.
Next, we send the request for notarization. There are two approaches.If the signing credentials (Apple ID, Team ID, app-specific password) are stored as a profile in the Keychain, we can use provide the profile name to access them:
1xcrun notarytool submit application.zip --wait --keychain-profile "MyProfileName" --output-format json
If the credentials are not in the Keychain (such as when you’re using an automated build/release process), you can provide the credentials directly:
1xcrun notarytool submit application.zip --wait --apple-id "firstname.lastname@example.org" --password "xapp-spec-pass-word" --team-id "ABCDEF7HIJ" --output-format json
This process normally takes under a minute, but can take longer with larger files. When it completes, the response will indicate success (
"status":"approved") or failure. Unlike previous versions of the tools, no polling is required to retrieve the result when we use
--wait. Because the response can be processed as JSON, it makes it easy to parse and interpret. The response will also include an identifier (
id) for the request that can be used to download log files which may contain more detailed information, including warnings or errors that occurred during the process. That log can be retrieved using the same tool (and credentials). For example, to use the profile to retrieve the logs for
11b1f653-3cd9-4b0e-ac07-a761d95e7e6f and save them to
/path/to/save/log.json, the command would be:
1xcrun notarytool log 11b1f653-3cd9-4b0e-ac07-a761d95e7e6f --keychain-profile "MyProfileName" /path/to/save/log.json
It’s a good practice to always retrieve and review the logs, even for successful notarizations.
The documentation describes an additional step called stapling which attaches the online results to the binary. This allows macOS to validate the executable even when network access is not available. For console applications, this process isn’t currently supported. Your work here is done. The application’s notarization details are stored online, so we can utilize the ZIP file (or the original binary) to distribute the notarized application.
For non-console apps (
.app packages), stapling is strongly recommended. First, you typically will delete the ZIP file that was used for notarization. Neither the ZIP nor its contents can be directly stapled. Instead, we have to modify the package. To staple the application package, the command is:
1xcrun stapler staple /path/to/my.app
Once the application is stapled, it can be compressed into a ZIP or packaged for distribution.
The stapler process uses CloudKit to securely download the tickets on port 443 using transfer acceleration from notary-submissions-prod.s3-accelerate.amazonaws.com by default. Currently, it requires access to these IP addresses:
.app package is stapled, a file named CodeResources will be added to the package. This file contains the notarization ticket.
At this point, the console application can be distributed using the ZIP file we created, distributed via Homebrew, or bundled into an installer package (
.pkg), or distributed on a disk image (
.dmg). Packages can also execute code, so they require code signing as well. That’s an article for another time.
I don’t recommend distributing the binary using direct download. Unlike Windows, Linux and macOS binaries do not rely on the
.exe extension to indicate that they are executable. Instead, they have a flag which indicates they have permission to execute. This can be preserved when the file is distributed using the methods above. If the file is not bundled or packaged, users will have to manually configure the file as an executable in order for it to work.
Good luck with your Mac projects and Happy DevOp’ing!