How to staple Apple notarization tickets manually (e.g. under linux)

197 views Asked by At

Recently (as of 2023-11-01) Apple has changed their notarization process.

I took the opportunity to drop Apple's own tools for this process (notarytool) and switch to a Python-based solution using their documented Web API for notarization

This works great and has the additional bonus, that I can now notarize macOS apps from linux (in the context of CI, I can provision linux runners much faster than macOS runners). hooray.

Since this went so smooth, I thought about moving more parts of my codesigning process to linux, and the obvious next step is find a solution for stapling the notarization tickets into application, replacing xcrun stapler staple MyApp.app

With the help of -vv and some scraps of online documentation, it turns out that it is very simple to obtain the notarization ticket if you know the code directory hash (CDhash) of your application.

the following will return a JSON-object containing (among other things) the base64-encoded notarization ticket, which just has to be decoded and copied into the .app bundle for stapling:

cdhash=8d817db79d5c07d0deb7daf4908405f6a37c34b4
curl -X POST -H "Content-Type: application/json" \
   --data "{ \"records\": { \"recordName\": \"2/2/${cdhash}\" }}" \
   https://api.apple-cloudkit.com/database/1/com.apple.gk.ticket-delivery/production/public/records/lookup \
| jq -r ".records[0] | .fields | .signedTicket | .value"

So, the only thing that is still missing for my stapler replacement is a way to obtain the code directory hash for a given application. On macOS (with the XCode tools installed), I can get this hash with codesign -d -vvv MyApp.app, but this obviously only works if I have the codesign binary at hand.

I've found a couple of python wrappers for stapling tickets, but all of them just call xcrun stapler staple under the hood. This is not what I want.

So my question is: How can I extract the code directory hash (CDhash) from a macOS application, without using macOS specific tools? (That is: How are CDhashes generated? I haven't found any documentation on this)

I would very much like to use use Python for this task. Ideally, such a solution would be cross-platform (so I can use it on macOS and Linux, and probably others as well).

2

There are 2 answers

5
Richard Barber On BEST ANSWER

How can I extract the code directory hash (CDhash) from a macOS application, without using macOS specific tools?

The CDhash of an app is the CDhash of the main executable in Contents/MacOS as identified in Contents/Info.plist

Each hash is stored at the end of the binary segment for each architecture in an XML statement. It can be grepped out.

The embedded cdhash is encoded in base64. The first one is for intel, the second for apple silicon:

% grep -i -a -A3 'cdhashes' myApp.app/Contents/MacOS/mainexec | sed -n '4p;9p' | cut -f 3  
HPhKLQv1j2SFYTmIgyUi/L6B9Yo=
TVNDrCQEL9A/DMWVmphntZAq7kc=

% printf "HPhKLQv1j2SFYTmIgyUi/L6B9Yo=" | base64 -d | hexdump -v -e '/1 "%02x" ' && echo ""
1cf84a2d0bf58f6485613988832522fcbe81f58a

% printf "TVNDrCQEL9A/DMWVmphntZAq7kc=" | base64 -d | hexdump -v -e '/1 "%02x" ' && echo ""
4d5343ac24042fd03f0cc5959a9867b5902aee47

Compared with the cdhash as reported by codesign:

% codesign -dvvv -a arm64 myApp.app
Executable=myApp.app/Contents/MacOS/mainexec
Identifier=com.mycompany.myApp
Format=app bundle with Mach-O universal (x86_64 arm64)
CodeDirectory v=20500 size=92199 flags=0x10000(runtime) hashes=2870+7 location=embedded
Hash type=sha256 size=32
CandidateCDHash sha256=4d5343ac24042fd03f0cc5959a9867b5902aee47
CandidateCDHashFull sha256=4d5343ac24042fd03f0cc5959a9867b5902aee4725c5a75775cd711aae76b709
Hash choices=sha256
CMSDigest=4d5343ac24042fd03f0cc5959a9867b5902aee4725c5a75775cd711aae76b709
CMSDigestType=2
Launch Constraints:
    None
CDHash=4d5343ac24042fd03f0cc5959a9867b5902aee47
0
umläute On

based on Richard's answer, this is what I've ended up using (with a lot more error checking):

import base64
import re
import plistlib

import requests

def extractEmbeddedPlists(filename):
    """extract embedded plist data sections from binary file"""
    # this is a very crude way to do it!
    # LATER properly parse the macho file (with something like 'macholibre')
    with open(filename, "rb") as f:
        data = f.read()
    try:
        return [
            plistlib.loads(_)
            for _ in re.findall(b"<plist.*?</plist>", data, flags=re.DOTALL)
        ]
    except:
        pass
    return []


def getCDHashes(app):
    """get code directory hashes from a bundle
    not all cdhashes might be valid!
    """
    info_plist = os.path.join(app, "Contents", "Info.plist")
    try:
        with open(info_plist, "rb") as f:
            info = plistlib.load(f)
    except:
        return []
    if "CFBundleExecutable" not in info:
        return []
    plists = extractEmbeddedPlists(os.path.join(app, "Contents", "MacOS", info["CFBundleExecutable"]))
    cdhashes = [_["cdhashes"] for _ in plists if "cdhashes" in _]
    hashes = {_.hex() for a in cdhashes for _ in a}
    return hashes

def getTicket(cdhash):
    """retrieve a notarization ticket for a given cdhash"""
    recordName = "2/2/%s" % cdhash
    data = {
        "records": {"recordName": recordName},
    }
    r = requests.post(ticket_url, json=data)

    j = r.json()
    record = j["records"][0]
    if record.get("serverErrorCode") == "NOT_FOUND":
        return

    ticket = record["fields"]["signedTicket"]["value"]
    return base64.b64decode(ticket)

def getTicket(app):
    for hash in getCDHashes(app):
        try:
            ticket = getTicket(hash)
            if ticket: return ticket
        except: pass

there are still some rough edges that are not handled yet (e.g. deciding which ticket to staple if the binary contains multiple CDHashes (one for each architecture of a universal binary) that yield different tickets), but the basics are now working.