Ken Muse

Notarizing .NET Console Apps for macOS


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 EnableCompressionInSingleFile and 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-memory is no longer required, but it was mandatory for earlier versions. While older documentation recommends com.apple.security.cs.allow-dyld-environment-variables and com.apple.security.cs.disable-library-validation, these are not generally required (and including them can reduce the overall application security). A basic .plist for 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.

  • force
    Overwrite any signatures or settings that already exist
  • verbose
    Provide a slightly more detailed response
  • timestamp
    Ensures that a timestamp server is used to guarantee the date/time of the signing. This is a requirement
  • sign
    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.
  • options
    Runtime hardening (“runtime”) is required for notarization. This forces processes to use a hardened runtime environment, with restrictions selectively relaxed by entitlements
  • 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.CodeDirectory, com.apple.cs.CodeRequirements, com.apple.cs.CodeRequirements-1, and 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.

Notarization

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 "youremail@domain.com" --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.

Stapler

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:

  • 17.248.128.0/18
  • 17.250.64.0/18
  • 17.248.192.0/19

Once an .app package is stapled, a file named CodeResources will be added to the package. This file contains the notarization ticket.

Distribution

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!