Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions scripts/ci/lib/notarization-ticket.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Architecture-aware checks supplement stapler's ticket validation. A ticket
# found by the host's CDHash can belong to a thin copy of a universal helper.

CODESIGN_TOOL="${CMUX_CODESIGN_TOOL:-/usr/bin/codesign}"
LIPO_TOOL="${CMUX_LIPO_TOOL:-lipo}"

# Prints arch=CDHash for every slice. Capture command results explicitly: a
# failing process substitution would otherwise let an empty slice set pass.
slice_cdhashes() {
local bundle="$1" executable architectures arch output hash
executable="$(python3 - "$bundle" <<'PY'
import pathlib, plistlib, sys
bundle = pathlib.Path(sys.argv[1])
with (bundle / 'Contents/Info.plist').open('rb') as handle:
name = plistlib.load(handle)['CFBundleExecutable']
if not name or pathlib.Path(name).name != name:
sys.exit('Invalid CFBundleExecutable')
print(bundle / 'Contents/MacOS' / name)
PY
)" || return 1
architectures="$("$LIPO_TOOL" -archs "$executable")" || return 1
if [ -z "$architectures" ]; then
echo "error: no architectures found in $executable" >&2
return 1
fi
architectures="$(printf '%s\n' "$architectures" | tr ' ' '\n' | sed '/^$/d' | sort -u)"
if [ -z "$architectures" ]; then
echo "error: empty architecture list for $executable" >&2
return 1
fi
while IFS= read -r arch; do
output="$("$CODESIGN_TOOL" -d -a "$arch" --verbose=4 "$bundle" 2>&1)" || {
printf '%s\n' "$output" >&2
return 1
}
hash="$(printf '%s\n' "$output" | sed -n 's/^CDHash=//p')"
if ! [[ "$hash" =~ ^[0-9a-f]{40}$ ]]; then
echo "error: invalid $arch CDHash for $bundle: $hash" >&2
return 1
fi
printf '%s=%s\n' "$arch" "$hash"
done <<< "$architectures"
}

# Info.plist is hashed into each slice's CodeDirectory. A new nonce before
# signing separates submissions across variants, channels, and reruns without
# changing the helper's bundle identifier or designated requirement.
isolate_helper_submission() {
python3 - "$1/Contents/Info.plist" <<'PY'
import pathlib, plistlib, sys, uuid
path = pathlib.Path(sys.argv[1])
data = path.read_bytes()
info = plistlib.loads(data)
info['CMUXNotarizationSubmission'] = str(uuid.uuid4())
fmt = plistlib.FMT_BINARY if data.startswith(b'bplist00') else plistlib.FMT_XML
path.write_bytes(plistlib.dumps(info, fmt=fmt))
PY
}

verify_ticket_contents_cover_slices() {
local log_file="$1" bundle="$2" slices
slices="$(slice_cdhashes "$bundle")" || return 1
python3 - "$log_file" "$bundle" "$slices" <<'PY'
import json, sys
log_file, bundle, slices = sys.argv[1:]
with open(log_file) as handle:
log = json.load(handle)
if log.get('status') != 'Accepted':
sys.exit(f'error: notarization log is not Accepted: {bundle}')
covered = {(entry.get('arch'), entry.get('cdhash')) for entry in log.get('ticketContents') or []}
for line in slices.splitlines():
arch, cdhash = line.split('=', 1)
if (arch, cdhash) not in covered:
sys.exit(f'error: accepted ticket is missing {arch} CDHash {cdhash}: {bundle}')
print(f'accepted ticket covers {arch} CDHash {cdhash}: {bundle}')
PY
}

# stapler validate remains mandatory to authenticate the ticket. This extra
# membership check catches a valid thin ticket attached to a universal bundle;
# it does not attempt to replace Apple's signature/ticket verification.
verify_stapled_ticket_covers_slices() {
local bundle="$1" slices
slices="$(slice_cdhashes "$bundle")" || return 1
python3 - "$bundle" "$slices" <<'PY'
import pathlib, sys
bundle, slices = sys.argv[1:]
ticket = pathlib.Path(bundle) / 'Contents/CodeResources'
if not ticket.is_file():
sys.exit(f'error: no stapled ticket: {bundle}')
data = ticket.read_bytes()
for line in slices.splitlines():
arch, cdhash = line.split('=', 1)
if bytes.fromhex(cdhash) not in data:
sys.exit(f'error: stapled ticket is missing {arch} CDHash {cdhash}: {bundle}')
print(f'stapled ticket covers {arch} CDHash {cdhash}: {bundle}')
PY
}
30 changes: 21 additions & 9 deletions scripts/ci/notarize-computer-use-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ usage: $0 [--start <state-file> | --finish <state-file>] <signed-host-app> <host

Without a phase flag, submit, wait, staple, and reseal synchronously.
--start uploads the signed helper and returns after persisting its submission.
--finish waits for that exact helper CDHash, staples it, and reseals the host.
--finish waits for that exact helper slice set, staples it, and reseals the host.
EOF
}

Expand Down Expand Up @@ -47,6 +47,8 @@ DITTO_TOOL="${CMUX_DITTO_TOOL:-/usr/bin/ditto}"
XCRUN_TOOL="${CMUX_XCRUN_TOOL:-xcrun}"
CODESIGN_TOOL="${CMUX_CODESIGN_TOOL:-/usr/bin/codesign}"
SPCTL_TOOL="${CMUX_SPCTL_TOOL:-spctl}"
# shellcheck source=lib/notarization-ticket.sh
source "$ROOT_DIR/scripts/ci/lib/notarization-ticket.sh"
# Gatekeeper learns about a fresh notarization ticket from Apple's CDN, which
# lags the notarytool "Accepted" status: usually by a minute or two, but
# nightly run 34208928547 (2026-09-08) was still rejected 4m50s after
Expand Down Expand Up @@ -115,9 +117,8 @@ HELPER_ZIP="$TMP_DIR/cmux-cua-notary.zip"
STANDALONE_DIR="$TMP_DIR/standalone"
STANDALONE_HELPER="$STANDALONE_DIR/cmux Computer Use.app"

helper_cdhash() {
"$CODESIGN_TOOL" -d --verbose=4 "$HELPER_PATH" 2>&1 \
| awk -F= '/^CDHash=/ { print $2; exit }'
helper_cdhashes() {
slice_cdhashes "$HELPER_PATH" | paste -sd ',' -
}

submission_value() {
Expand All @@ -137,6 +138,11 @@ start_submission() {
exit 1
fi

# A signing timestamp does not change a slice CDHash. Isolate this
# submission before signing so thin and universal builds cannot retrieve
# each other's notarization tickets through a shared CDHash.
isolate_helper_submission "$HELPER_PATH"

# Give the helper its final Developer ID signature before upload. Later host
# signing must use all-except-computer-use so this exact CDHash survives until
# finish staples the ticket and re-seals only the outer app.
Expand All @@ -148,7 +154,7 @@ start_submission() {
--entitlements "$HELPER_ENTITLEMENTS" \
"$HELPER_PATH"
"$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH"
submitted_cdhash="$(helper_cdhash)"
submitted_cdhash="$(helper_cdhashes)"
if [ -z "$submitted_cdhash" ]; then
echo "Could not resolve Computer Use helper CDHash before notarization" >&2
exit 1
Expand All @@ -171,7 +177,7 @@ start_submission() {
umask 077
{
printf 'submission_id=%s\n' "$submit_id"
printf 'cdhash=%s\n' "$submitted_cdhash"
printf 'cdhashes=%s\n' "$submitted_cdhash"
} > "$state_tmp"
/bin/mv "$state_tmp" "$SUBMISSION_FILE"
echo "Computer Use helper notarization submitted: $submit_id ($submit_status)"
Expand All @@ -184,13 +190,14 @@ finish_submission() {
exit 1
fi
submit_id="$(submission_value submission_id)"
submitted_cdhash="$(submission_value cdhash)"
submitted_cdhash="$(submission_value cdhashes)"
if [ -z "$submit_id" ] || [ -z "$submitted_cdhash" ]; then
echo "Computer Use notarization state is incomplete: $SUBMISSION_FILE" >&2
exit 1
fi

current_cdhash="$(helper_cdhash)"
"$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH"
current_cdhash="$(helper_cdhashes)"
if [ "$current_cdhash" != "$submitted_cdhash" ]; then
echo "Computer Use helper changed after notarization submission" >&2
echo " submitted CDHash: $submitted_cdhash" >&2
Expand Down Expand Up @@ -223,16 +230,20 @@ finish_submission() {
"$XCRUN_TOOL" notarytool log "$submit_id" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD"
--password "$APPLE_APP_SPECIFIC_PASSWORD" > "$TMP_DIR/notary-log.json"
cat "$TMP_DIR/notary-log.json"
verify_ticket_contents_cover_slices "$TMP_DIR/notary-log.json" "$HELPER_PATH"
"$XCRUN_TOOL" stapler staple "$HELPER_PATH"
"$XCRUN_TOOL" stapler validate "$HELPER_PATH"
verify_stapled_ticket_covers_slices "$HELPER_PATH"
"$CODESIGN_TOOL" --verify --strict --verbose=2 "$HELPER_PATH"

# Validate the same shape the runtime launches: a standalone copy outside the
# host app. This also proves that the stapled ticket survives the copy.
mkdir -p "$STANDALONE_DIR"
"$DITTO_TOOL" "$HELPER_PATH" "$STANDALONE_HELPER"
"$XCRUN_TOOL" stapler validate "$STANDALONE_HELPER"
verify_stapled_ticket_covers_slices "$STANDALONE_HELPER"
"$CODESIGN_TOOL" --verify --strict --verbose=2 "$STANDALONE_HELPER"
assess_with_gatekeeper "$STANDALONE_HELPER"

Expand All @@ -242,6 +253,7 @@ finish_submission() {
"$SIGN_BUNDLE_TOOL" "$APP_PATH" "$APP_ENTITLEMENTS" "$SIGNING_IDENTITY"
"$CODESIGN_TOOL" --verify --deep --strict --verbose=2 "$APP_PATH"
"$XCRUN_TOOL" stapler validate "$HELPER_PATH"
verify_stapled_ticket_covers_slices "$HELPER_PATH"
rm -f "$SUBMISSION_FILE"

echo "Computer Use helper notarized and stapled: $HELPER_PATH"
Expand Down
Loading
Loading