Archaeology

Notarization Tickets

Much of this discussion is based on reverse-engineering of file formats and frameworks, but we haven't bothered to pepper it with qualifiers. Since our reverse-engineering skills are not beyond reproach, and macOS is always changing, a grain of salt is advised. If you have corrections to any details, please do get in touch.

What Is A Notarization Ticket?

A ticket is an Apple-signed file that communicates the notarization status of one or more executables. Gatekeeper consumes these tickets to determine if a given app or other component is properly notarized.

A notarization ticket can be stapled onto — or more simply, attached to — an app bundle, a disk image or a macOS Installer package. Alternatively, in the absence of stapling, the ticket can be fetched via the Internet.

As the stapler(1) man page puts it:

Developer ID requires apps to be notarized before distribution. A ticket contains a list of the code signatures for executables within a supported file format. The stapler utility downloads and attaches (staples) a ticket to these files, enabling Gatekeeper to verify that executables they contain have been properly notarized.

Here, a “list of code signatures” really means a list of code signing digests — these are (usually) SHA-256 digests, truncated to the first 20 bytes. Each code signing digest is computed from another list of digests — called the code directory — which identifies the pages of the Mach-O binary, the contents of the Info.plist file, and other pieces of a single code signature. The code signing digest is also called the code directory hash or simply the cdhash.

The code signing digest is the data that is signed — with a Developer ID certificate or the like — to produce the actual code signature. In this way, the code signing digest succinctly represents a specific bit of signed code. Generally, the code signing digest will change whenever the executable or resources change in any way, so every version of the same app will have a unique digest. If the executable is Universal, there will be a separate digest, and a separate signature, for each architecture.

Whichever way the ticket is retrieved — stapled or via the Internet — Gatekeeper imports the notarized code signing digests into its SQLite database at /var/db/SystemPolicyConfiguration/Tickets, presumably after vetting that the ticket was validly signed by Apple. This allows Gatekeeper to subsequently check the notarization status of any piece of signed code, using only its code signing digest.

The Ticket Binary Format

Based on our reverse-engineering, the ticket appears to have the following binary structure.

We inferred this information by examining ticket files and some amount of reversing of /usr/libexec/syspolicyd on macOS 10.14. The implementation may have changed since then, but as far as we know, the ticket format described here is still accurate.

The ticket file starts with a header in this form:

struct ticket_header
{
    uint32_t ticket_magic;           // 0x68633873 ("s8ch")
    uint32_t ticket_version;         // 0x00000001
    uint32_t ticket_signer_length;   // DER-encoded SEQUENCE of signing certificate ("Software Ticket Signing") plus intermediate
    uint32_t ticket_content_length;  // struct ticket_content_header + one or more struct ticket_content_cdhash
};

There is a 4-byte magic value (don't ask us what s8ch is short for), and what appears to be a 4-byte format version (still at 1 the last we checked). Note that all of the integers in this format appear to be strictly Little Endian (unlike the [older] pieces of the code signing binary format, which generally stick to Network Byte Order by convention).

The magic and version are followed by the lengths of the next two sections of the ticket: the certificate chain and the actual ticket content.

The certificate chain immediately follows the ticket_header, and consists of two X.509v3 certificates in an ASN.1 SEQUENCE, encoded using DER. The first certificate is the Software Ticket Signing one, which as we'll see below, is used to sign the ticket itself. The second certificate is the intermediate certificate authority that issued the signing certificate. The intermediate certificate is issued by an Apple Root CA, which is found in the System Roots keychain and so is not explicit in the ticket.

After the certificate chain is the content of the ticket, which starts with its own header of this form:

struct ticket_content_header
{
    uint32_t ticket_content_magic;     // 0x6B743867 ("g8tk")
    uint16_t ticket_cdhash_type;       // from SecCSDigestAlgorithm (e.g. 0x02 for SHA256)
    uint16_t ticket_cdhash_length;     // size of each hash (e.g. 0x14 [20 bytes] for SHA256 as truncated)
    uint32_t ticket_cdhash_count;      // number of hashes in ticket (e.g. one for each notarized code object)
    uint32_t ticket_content_flags;
    uint64_t ticket_content_timestamp; // notarization time as seconds since 1-1-1970 (e.g. 0x5F3EA5A7 or 2020-08-20 16:32:39 UTC)
};

This header has its own magic value (this one appears to be short for Gatekeeper), and general information about the type and size of each code signing digest in the ticket. These are almost always SHA-256 digests, truncated to the first 20 bytes (as described above for code signatures), but as we'll see below, individual digests can differ.

There is also a timestamp, which seems to correspond (more or less) to when the notarization was granted (although perhaps it is when the ticket was issued?), and a field that we're guessing contains some sort of flags but is always zero in our experience.

After this ticket_content_header are ticket_cdhash_count digests of the form:

struct ticket_content_cdhash
{
    uint8_t ticket_content_cdhash_type;                           // SecCSDigestAlgorithm (overrides ticket_cdhash_type)
    uint8_t ticket_content_cdhash_digest[ ticket_cdhash_length ]; // the digest data
};

Usually, both ticket_cdhash_type and each ticket_content_cdhash_type are kSecCodeSignatureHashSHA256 (2).

However, a macOS Installer package usually has a SHA-1 digest (this is the xar TOC checksum), so ticket_content_cdhash_type will be kSecCodeSignatureHashSHA1 (1) for that digest. (Since the SHA-256 is truncated and both are 20 bytes, it isn't clear how or if the size of ticket_content_cdhash_digest is specified?) Note that the ticket for a package will also contain digests for the notarized executables that it installs, and these will still (generally) be SHA-256 digests.

The above is correct for all of the tickets that we've examined, although the code suggests there is an alternate representation (perhaps an older one?) in which the ticket_content_cdhash_type is omitted entirely. This is apparently expected only if ticket_cdhash_type is kSecCodeSignatureHashSHA1, implying therefore that all digests are strictly SHA-1. But we haven't seen this used in practice.

Finally, after the last ticket_content_cdhash entry, we come to the actual ticket signature: this is the last 72 bytes of the ticket, and is generated from a SHA-256 digest of the entire ticket content (from the ticket_header through the last ticket_content_cdhash), using the Software Ticket Signing certificate. We're not cryptography experts (by any means) but can note that SecKeyVerifySignature() with kSecKeyAlgorithmECDSASignatureMessageX962SHA256 appears to properly verify this signature — and is what syspolicyd(8) used the last we checked.

Of course, for a ticket to be trusted by macOS, the signature must not only be valid, but also must be produced by an appropriate Apple-issued certificate, i.e. a Software Ticket Signing one. This is enforced by a trust policy defined by the Security framework. This policy — which has the OID 1.2.840.113635.100.1.90 and is created by the private SecPolicyCreateAppleDeveloperIDPlusTicket() function — requires special markers on both the intermediate certificate (1.2.840.113635.100.6.2.17) and leaf certificate (1.2.840.113635.100.6.1.30), as well as having an Apple Root CA anchor. Notably, however, it does not require that the signing certificate be unexpired; it appears that the Software Ticket Signing certificates are frequently re-issued with relatively short validity periods (less than one year).

Stapling

As noted above, stapling is an optional step that allows notarization to be checked even when the computer is not connected to the Internet, and thus can't query Apple servers to fetch the ticket. This section covers how stapling actually attaches the ticket to the “supported file formats.”

We haven't delved into the network-query aspect of the notarization check, but would guess that the code signing digest of the app gets sent to some Apple service — which looks to be built on CloudKit — and if there has been any ticket issued that contained that digest, it gets returned.

App Bundles

The simplest (and probably most common) case is stapling the ticket to an app bundle. This simply stores the entire ticket at the bundle subpath Contents/CodeResources. Since a ticket usually contains digests for all of the notarized components within the app — such as bundled frameworks, dylibs or XPC services — only the top-level app gets this file.

Don't confuse the ticket path Contents/CodeResources with the code signature path Contents/_CodeSignature/CodeResources, as these are quite different. The latter is a standard piece of the code signature that identifies the (mostly non-executable) resources within the bundle, and is then referenced by the code directory.

Contents/CodeResources is, in fact, an archaic version of Contents/_CodeSignature/CodeResources, and in the distant past, the former was a symbolic link to the latter, but this usage has long since disappeared. We suspect that, due to this history, Contents/CodeResources has long been exempted from code signature validity checks, and as such, was a convenient place to dump the notarization ticket without disturbing the code signature (even on versions of macOS that pre-date the entire notarization scheme).

The notarization ticket isn't the only thing that might be found at this archaic Contents/CodeResources path. The Install macOS app — installed by a macOS Full Installer package — has what appears to be an IMG4 file at this location.

Disk Images

If a notarized app is delivered on a disk image, an alternative to stapling the app bundle is to staple the disk image instead. (This can be especially handy if a disk image is used to submit the app for notarization in the first place, as the same disk image can then be stapled and distributed.)

To understand how a disk image is stapled, we first have to explain how a disk image stores its code signature. The ability to add a code signature to a UDIF-format disk image has existed since OS X 10.11 (El Capitan); a previously-unused piece of the fixed-length UDIF trailer structure was repurposed to point to the offset and length of the code signature data. So, when a disk image is signed — i.e. using codesign(1) — the code signature data is inserted and the trailer updated. The code directory part of the code signature contains a digest of the UDIF trailer, so the code signing digest of a disk image covers this fundamental piece of the disk image format.

Stapling a notarization ticket to the (signed) disk image extends this further, by appending the ticket onto the existing code signature data, and adjusting the length in the UDIF trailer accordingly. This can be done without invalidating the code signature, only because the stored digest of the UDIF trailer actually excludes the code signature length itself. The disk image is otherwise unchanged from this operation.

Note that, if you staple the ticket to the disk image, it is not necessary to staple the app bundle inside that disk image. Indeed, doing so would require multiple notarization steps (since stapling the app would change the disk image), and we don't know if that's even possible. Anyway, we assume that macOS imports the ticket information when the disk image is opened, so that the digests are available when the app is subsequently installed and opened. We haven't investigated this process, however.

Installer Packages

If the notarized software is not a simple (single) app bundle, it might be distributed using a macOS Installer package. This is most likely the same package that was submitted for notarization, which implicitly notarizes everything installed by the package. Indeed, the ticket resulting from notarizing a package will contain a digest for the package itself — the SHA-1 digest of the xar TOC — and digests for all of the executables and other notarizable components installed by the package (in much the same way that notarizing a complex app bundle will notarize all the components within it).

We assume that the ticket is examined at the time that the package is opened by the macOS Installer — or at least before the installation begins — although we haven't investigated this process.

Stapling a notarization ticket to a (signed) installer package appends an entirely new structure onto the end of the existing package data. (The xar header and TOC structures are such that gluing extra data onto the end of the archive file does not invalidate anything from the xar perspective.) This package trailer format is defined by Security.framework — c.f. registerStapledTicketInPackage() — and is defined as follows:

typedef struct __attribute__((packed)) _package_trailer
{
    uint8_t     magic[4];       // 0x726C3874 ("t8lr")
    uint16_t    version;        // currently 1
    uint16_t    type;           // TrailerType
    uint32_t    length;         // size of the contents *preceding* this trailer
    uint8_t     reserved[4];
    
} package_trailer_t;
where the type is defined by:
enum TrailerType : uint16_t
{
    TrailerTypeInvalid = 0,
    TrailerTypeTerminator,
    TrailerTypeTicket,
};

The integer values here appear to be strictly Little Endian.

For the first trailer (from the end of the file), the type will be TrailerTypeTicket (2). The actual ticket is inserted before this trailer.

Another trailer is inserted before the ticket data, with a type of TrailerTypeTerminator (1), which marks the end of the trailer structure: everything before that trailer is part of the xar proper. This terminating trailer, and the explicit length specifiers, allows reading code to find each trailer, even if it doesn't recognize the given type value. (This is presumably to allow other kinds of trailers to be added, in a way that is backward compatible, but if that's actually used anywhere, we haven't seen it.)