xref: /trunk/main/solenv/bin/macosx-codesign.sh (revision c42fc0a9ce106621d53d9635e05c6ca7fd6a53e1)
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