1#!/bin/bash 2# 3# Code-sign a built Apache OpenOffice .app (or .dmg) on macOS. 4# 5# ./solenv/bin/macosx-codesign.sh [options] <OpenOffice.app|installer.dmg> ... 6# 7# Options: 8# -i, --identity ID codesign identity; "-" = ad-hoc (default, or 9# $MACOSX_CODESIGNING_IDENTITY when set) 10# -e, --entitlements entitlements plist (default: macosx-codesign-entitlements.plist) 11# -k, --keychain PATH keychain holding the identity (default: the search list) 12# --hardened force hardened runtime even for an ad-hoc signature 13# (implied by a real identity) 14# --notarize PROFILE 15# after signing, submit to the Apple notary service with 16# the "xcrun notarytool store-credentials" keychain 17# profile PROFILE and staple the ticket (.dmg, or a .app 18# zipped for submission); needs a real identity 19# --release fail if the Gatekeeper (spctl) assessment rejects the 20# result, instead of only reporting it; ignored for an 21# ad-hoc identity, which spctl always rejects regardless 22# of notarization 23# --verify only report the current signing state, change nothing 24# 25# The linker already ad-hoc-signs each Mach-O it produces, which is why the 26# binaries load at all on arm64. What it does not do is seal the *bundles*: 27# without a _CodeSignature/CodeResources the .app has no identity, Gatekeeper 28# rejects it once it carries a quarantine flag, and it can never be notarized. 29# 30# This signs inside-out -- every Mach-O, then the nested bundles, then the app. 31# It relies on the installation being laid out so that Contents/MacOS holds 32# only the soffice launcher (see scp2/source/ooo/common_brand.scp); codesign 33# refuses to seal a bundle with anything else in there. 34 35set -euo pipefail 36 37SRCDIR=$(cd "$(dirname "$0")" && pwd) 38IDENTITY="${MACOSX_CODESIGNING_IDENTITY:--}" 39ENTITLEMENTS="$SRCDIR/macosx-codesign-entitlements.plist" 40KEYCHAIN="${MACOSX_CODESIGNING_KEYCHAIN:-}" 41NOTARY_PROFILE="" 42HARDENED=no 43RELEASE=no 44VERIFY_ONLY=no 45TARGETS=() 46 47while [ $# -gt 0 ]; do 48 case "$1" in 49 -i|--identity) 50 [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } 51 IDENTITY="$2"; shift 2 ;; 52 -e|--entitlements) 53 [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } 54 ENTITLEMENTS="$2"; shift 2 ;; 55 -k|--keychain) 56 [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } 57 KEYCHAIN="$2"; shift 2 ;; 58 --hardened) HARDENED=yes; shift ;; 59 --notarize) 60 [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; exit 2; } 61 NOTARY_PROFILE="$2"; shift 2 ;; 62 --release) RELEASE=yes; shift ;; 63 --verify) VERIFY_ONLY=yes; shift ;; 64 -h|--help) sed -n '2,33p' "$0"; exit 0 ;; 65 -*) echo "unknown option: $1" >&2; exit 2 ;; 66 *) TARGETS+=("$1"); shift ;; 67 esac 68done 69 70[ ${#TARGETS[@]} -gt 0 ] || { echo "usage: $(basename "$0") [options] <app-or-dmg> ..." >&2; exit 2; } 71[ "$IDENTITY" = "-" ] || HARDENED=yes 72if [ -n "$NOTARY_PROFILE" ] && [ "$IDENTITY" = "-" ]; then 73 echo "--notarize needs a Developer ID identity, not an ad-hoc signature" >&2 74 exit 2 75fi 76 77sign_one() { 78 local path="$1"; shift 79 local args=(--force --sign "$IDENTITY" --timestamp=none) 80 if [ "$IDENTITY" != "-" ]; then 81 args=(--force --sign "$IDENTITY" --timestamp) 82 fi 83 if [ -n "$KEYCHAIN" ]; then 84 args+=(--keychain "$KEYCHAIN") 85 fi 86 codesign "${args[@]}" "$@" "$path" 87} 88 89sign_executable() { 90 local path="$1"; shift 91 if [ "$HARDENED" = yes ]; then 92 sign_one "$path" --options runtime --entitlements "$ENTITLEMENTS" "$@" 93 else 94 sign_one "$path" "$@" 95 fi 96} 97 98sign_disk_image() { 99 local path="$1" 100 local args=(--force --sign "$IDENTITY" --timestamp) 101 if [ -n "$KEYCHAIN" ]; then 102 args+=(--keychain "$KEYCHAIN") 103 fi 104 codesign "${args[@]}" "$path" 105} 106 107# notarytool exits 0 even when the notary service rejects the submission, so 108# a successful staple is the only proof the ticket exists. A .app cannot be 109# submitted as-is; it goes up zipped and the ticket is stapled to the bundle. 110notarize() { 111 local target="$1" upload="$1" zip="" 112 case "$target" in 113 *.dmg) ;; 114 *) 115 zip=$(mktemp -d)/$(basename "$target").zip 116 ditto -c -k --keepParent "$target" "$zip" 117 upload="$zip" ;; 118 esac 119 echo "==> notarizing $target (profile: $NOTARY_PROFILE)" 120 if ! xcrun notarytool submit --wait --keychain-profile "$NOTARY_PROFILE" "$upload"; then 121 echo "notarization submission failed" >&2 122 [ -z "$zip" ] || rm -rf "$(dirname "$zip")" 123 return 1 124 fi 125 [ -z "$zip" ] || rm -rf "$(dirname "$zip")" 126 if ! xcrun stapler staple "$target"; then 127 echo "stapling failed: the submission was probably rejected; run 'xcrun notarytool log' for details" >&2 128 return 1 129 fi 130 echo " stapled notarization ticket to $target" 131 if ! xcrun stapler validate "$target"; then 132 echo "stapler validate: FAILED (staple reported success but does not validate)" >&2 133 return 1 134 fi 135 echo " stapled ticket validates" 136} 137 138# hdiutil create/codesign only ever touch the staged tree before it goes into 139# the image; nothing checks the *finished* image's own structure, or that the 140# application actually mounts and verifies from inside it. See the "older 141# hdiutil makehybrid" note above sign_app() for why that distinction matters. 142verify_dmg_contents() { 143 local dmg="$1" mnt rc=0 app 144 if ! hdiutil verify "$dmg" >/dev/null; then 145 echo "hdiutil verify: FAILED" >&2 146 return 1 147 fi 148 echo "hdiutil verify: OK" 149 150 mnt=$(hdiutil attach -readonly -nobrowse "$dmg" | tail -1 | awk -F'\t' '{print $NF}') 151 if [ -z "$mnt" ] || [ ! -d "$mnt" ]; then 152 echo "could not mount $dmg to verify its contents" >&2 153 return 1 154 fi 155 for app in "$mnt"/*.app; do 156 [ -d "$app" ] || continue 157 if ! codesign --verify --deep --strict --verbose=2 "$app"; then 158 echo "enclosed application verify: FAILED ($app)" >&2 159 rc=1 160 fi 161 done 162 hdiutil detach "$mnt" -quiet 163 if [ "$rc" -eq 0 ]; then 164 echo " enclosed application verifies from the mounted image" 165 fi 166 return "$rc" 167} 168 169report() { 170 local target="$1" spctl_rc=0 dv 171 echo "--- $target" 172 dv=$(codesign -dv --verbose=2 "$target" 2>&1) || true 173 printf '%s\n' "$dv" | grep -E 'Identifier|Format|CodeDirectory|Authority|TeamIdentifier|Sealed' || true 174 if ! codesign --verify --deep --strict --verbose=2 "$target"; then 175 echo "verify: FAILED" >&2 176 return 1 177 fi 178 echo "verify: OK" 179 case "$target" in 180 *.dmg) 181 verify_dmg_contents "$target" || return 1 182 spctl --assess --type open --context context:primary-signature --verbose=4 "$target" || spctl_rc=$? ;; 183 *) spctl --assess --type exec --verbose=4 "$target" || spctl_rc=$? ;; 184 esac 185 # spctl rejects every ad-hoc signature outright, notarized or not, so only 186 # a real signature's rejection is meaningful enough to fail the build on. 187 # Detect that from the target's own signature (an "Authority=" line means a 188 # real CA-chained identity; ad-hoc has none) rather than the -i/env 189 # default, so this is also correct when --verify checks an 190 # already-signed artifact without re-passing -i. 191 if [ "$spctl_rc" -ne 0 ] && [ "$RELEASE" = yes ] && printf '%s\n' "$dv" | grep -q 'Authority='; then 192 echo "spctl: FAILED (fatal under --release)" >&2 193 return 1 194 fi 195} 196 197# codesign rewrites every Mach-O it signs and writes _CodeSignature/ into 198# every bundle it seals; the installer stages files read-only. Open just those 199# for writing rather than the whole tree, so the staged UnixRights survive. 200open_for_signing() { 201 local b="$1" d 202 for d in "$b" "$b/Contents" "$b"/Versions/*/; do 203 if [ -d "$d" ]; then chmod u+w "$d"; fi 204 done 205 find "$b" -maxdepth 3 -name _CodeSignature -exec chmod -R u+w {} + 2>/dev/null || true 206} 207 208sign_app() { 209 local app="$1" 210 [ -d "$app/Contents" ] && [ -f "$app/Contents/Info.plist" ] || { 211 echo "not an application bundle: $app" >&2 212 return 1 213 } 214 echo "==> signing $app (identity: $IDENTITY, hardened: $HARDENED)" 215 216 # Quarantine and other xattrs make codesign fail or produce an unstable seal. 217 xattr -cr "$app" 2>/dev/null || true 218 219 # A bundle's main executable is signed as part of its bundle, not on its own: 220 # codesign silently redirects such a path to the enclosing bundle. 221 local bundles=() main_execs=() b exe 222 while IFS= read -r b; do bundles+=("$b"); done < <(find "$app" \ 223 \( -name '*.app' -o -name '*.framework' -o -name '*.bundle' \ 224 -o -name '*.mdimporter' -o -name '*.plugin' -o -name '*.qlgenerator' \) \ 225 | awk '{ print gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-) 226 for b in ${bundles[@]+"${bundles[@]}"}; do 227 exe=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' "$b/Contents/Info.plist" 2>/dev/null) || continue 228 main_execs+=("$b/Contents/MacOS/$exe") 229 done 230 231 # Find every Mach-O with one batched file(1) run instead of a process per 232 # file (an installation holds ~10k). --print0 emits "path\0: type\n". 233 local machos=() executable_machos=() f type 234 while IFS= read -r -d '' f && IFS= read -r type; do 235 case "$type" in 236 ": Mach-O"*) 237 machos+=("$f") 238 case "$type" in *" executable"*) executable_machos+=("$f") ;; esac 239 ;; 240 esac 241 done < <(find "$app" -type f -print0 | xargs -0 file --no-pad --print0 -- 2>/dev/null) 242 "$SRCDIR/macosx-check-load-commands.sh" "$app" 243 # codesign rewrites a Mach-O through a temporary file beside it, so the 244 # containing directory has to be writable as well. 245 if [ ${#machos[@]} -gt 0 ]; then 246 local dirs=() 247 for f in "${machos[@]}"; do dirs+=("${f%/*}"); done 248 chmod u+w "${machos[@]}" 249 printf '%s\n' "${dirs[@]}" | sort -u | tr '\n' '\0' | xargs -0 chmod u+w 250 fi 251 for b in ${bundles[@]+"${bundles[@]}"}; do open_for_signing "$b"; done 252 253 # 1. every Mach-O object, deepest path first. Process entitlements belong 254 # on executables; dylibs and plug-ins inherit the host process's policy. 255 local count=0 256 if [ ${#machos[@]} -gt 0 ]; then 257 while IFS= read -r f; do 258 case " ${main_execs[*]-} " in *" $f "*) continue ;; esac 259 case " ${executable_machos[*]-} " in 260 *" $f "*) sign_executable "$f" ;; 261 *) sign_one "$f" ;; 262 esac 263 count=$((count + 1)) 264 done < <(printf '%s\n' "${machos[@]}" | awk '{ print gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-) 265 fi 266 echo " signed $count Mach-O objects" 267 268 # 2. nested bundles, deepest first, so each seal covers already-signed contents 269 for b in ${bundles[@]+"${bundles[@]}"}; do 270 [ "$b" = "$app" ] && continue 271 case "$b" in 272 *.app) sign_executable "$b" ;; 273 *) sign_one "$b" ;; 274 esac 275 echo " sealed nested bundle: ${b#"$app"/}" 276 done 277 278 # 3. the app bundle itself 279 sign_executable "$app" 280 echo " sealed $app" 281} 282 283for target in "${TARGETS[@]}"; do 284 [ -e "$target" ] || { echo "no such path: $target" >&2; exit 1; } 285 case "$target" in 286 *.dmg) 287 if [ "$VERIFY_ONLY" = yes ]; then report "$target"; continue; fi 288 # A .dmg is signed as a whole; the .app inside must already be signed. 289 if [ "$IDENTITY" = "-" ]; then 290 echo "refusing to ad-hoc sign a .dmg (pointless); pass -i <Developer ID>" >&2 291 exit 1 292 fi 293 sign_disk_image "$target" 294 [ -z "$NOTARY_PROFILE" ] || notarize "$target" 295 report "$target" 296 ;; 297 *) 298 if [ "$VERIFY_ONLY" = yes ]; then report "$target"; continue; fi 299 sign_app "$target" 300 [ -z "$NOTARY_PROFILE" ] || notarize "$target" 301 # spctl rejects a Developer ID app that isn't notarized yet, so this 302 # must run after notarize, not inside sign_app() - see the .dmg 303 # branch above, which already had this order right. 304 report "$target" 305 ;; 306 esac 307done 308