#!/bin/bash # Signs an app bundle with the persistent release identity, or verifies a # signed bundle against the pinned certificate. See docs/macos-signing.md. # # Usage: scripts/sign-macos-release.sh sign|verify /path/to/Whisper.app # # `sign` reads WHISPER_SIGNING_CERTIFICATE (base64 PKCS#12) and # WHISPER_SIGNING_PASSWORD, or, when both are unset, the saved identity in # ~/.config/whisper/signing/. It imports the identity into a temporary # keychain that is removed on exit, success or failure. # # Every signed component gets a designated requirement bound to its identifier # and the pinned certificate, so macOS keeps one identity across releases. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" certificate_pem="$root/packaging/signing-certificate.pem" entitlements="$root/packaging/entitlements.plist" app_id="com.softmaxe.whisper" signing_dir="$HOME/.config/whisper/signing" die() { echo "[sign] $*" >&2 exit 1 } fingerprint() { local value value="$(openssl x509 -in "$certificate_pem" -noout -fingerprint -sha1 2>/dev/null)" || die "The pinned signing certificate is missing or invalid." value="${value##*=}" value="${value//:/}" [[ "$value" =~ ^[0-9A-Fa-f]{40}$ ]] || die "Invalid signing certificate fingerprint." printf '%s' "$value" | tr 'a-f' 'A-F' } requirement() { printf 'identifier "%s" and certificate leaf = H"%s"' "$1" "$2" } # Lowercases certificate hashes so requirements compare as text. normalize() { perl -pe 's/H"([0-9A-Fa-f]{40})"/H"\L$1"/g' } # Nested code first, then the app; one "identifierpathkind" per line. components() { local app="$1" bin="$1/Contents/Resources/bin" file framework_id file_kind [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" == "$app_id" ]] || die "Whisper must keep CFBundleIdentifier $app_id." for file in "$bin"/*; do if [[ -f "$file" && ! -L "$file" ]]; then file_kind="$(file -b "$file")" || die "Cannot inspect component $file." if [[ "$file_kind" == Mach-O* ]]; then printf '%s.%s\t%s\thelper\n' "$app_id" "$(basename "$file")" "$file" fi fi done if [[ -d "$bin/MediaRemoteAdapter.framework" ]]; then framework_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \ "$bin/MediaRemoteAdapter.framework/Resources/Info.plist")" || die "Cannot read the framework identifier." [[ -n "$framework_id" ]] || die "The framework identifier is empty." printf '%s\t%s\tframework\n' "$framework_id" "$bin/MediaRemoteAdapter.framework" fi printf '%s\t%s\tapp\n' "$app_id" "$app" } # Validate in the caller's control flow; process substitution hides failures. component_list() { local app="$1" listing id path kind found_app=false listing="$(components "$app")" || die "Cannot list signed components." while IFS=$'\t' read -r id path kind; do if [[ "$id" == "$app_id" && "$path" == "$app" && "$kind" == app ]]; then found_app=true fi done <<<"$listing" [[ "$found_app" == true ]] || die "Component list does not include the app." printf '%s\n' "$listing" } verify() { local app="$1" fp id path kind expected actual listing listing="$(component_list "$app")" || die "Cannot verify an incomplete component list." fp="$(fingerprint)" codesign --verify --deep --strict "$app" || die "Signature check failed for $app." while IFS=$'\t' read -r id path kind; do expected="$(requirement "$id" "$fp")" codesign --verify --strict -R "=$expected" "$path" || die "$(basename "$path") does not satisfy its release requirement." actual="$(codesign --display --requirements - "$path" 2>/dev/null | sed -n 's/^designated => //p')" if [[ "$(normalize <<<"$actual")" != "$(normalize <<<"$expected")" || "$actual" == *cdhash* ]]; then die "Unstable designated requirement for $(basename "$path")." fi done <<<"$listing" echo "[sign] Verified release signatures against the pinned certificate." } sign() { local app="$1" fp identity password work keychain keychain_password listing listing="$(component_list "$app")" || die "Cannot sign an incomplete component list." fp="$(fingerprint)" if [[ -n "${WHISPER_SIGNING_CERTIFICATE+set}" || -n "${WHISPER_SIGNING_PASSWORD+set}" ]]; then [[ -n "${WHISPER_SIGNING_CERTIFICATE:-}" && -n "${WHISPER_SIGNING_PASSWORD:-}" ]] || die "Set both WHISPER_SIGNING_CERTIFICATE and WHISPER_SIGNING_PASSWORD." password="$WHISPER_SIGNING_PASSWORD" else [[ -s "$signing_dir/identity.p12" && -s "$signing_dir/password" ]] || die "Signing identity is missing. Restore the Whisper signing backup or set the release signing secrets." password="$(<"$signing_dir/password")" fi work="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/whisper-signing.XXXXXX")" && pwd -P)" keychain="$work/signing.keychain-db" keychain_password="$(openssl rand -base64 32)" identity="$work/identity.p12" # shellcheck disable=SC2064 trap "cleanup '$work' '$keychain'" EXIT ( umask 077 if [[ -n "${WHISPER_SIGNING_CERTIFICATE:-}" ]]; then printf '%s' "$WHISPER_SIGNING_CERTIFICATE" | tr -d '[:space:]' | base64 -D >"$identity" 2>/dev/null || die "WHISPER_SIGNING_CERTIFICATE must contain a base64-encoded PKCS#12 file." else cp "$signing_dir/identity.p12" "$identity" fi ) [[ -s "$identity" ]] || die "WHISPER_SIGNING_CERTIFICATE must contain a base64-encoded PKCS#12 file." security create-keychain -p "$keychain_password" "$keychain" >/dev/null || die "Creating temporary signing keychain failed." # codesign --keychain narrows identity lookup but still uses the search list. search_list_add "$keychain" security unlock-keychain -p "$keychain_password" "$keychain" >/dev/null || die "Unlocking signing keychain failed." security set-keychain-settings -lut 3600 "$keychain" >/dev/null # security errors can echo arguments, which include the password. security import "$identity" -k "$keychain" -P "$password" -T /usr/bin/codesign >/dev/null 2>&1 || die "Importing signing identity failed." security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null 2>&1 || die "Configuring signing key access failed." # No -v: a self-signed identity need not be trusted system-wide. security find-identity -p codesigning "$keychain" | grep -qi ") $fp \"" || die "The imported signing identity does not match the pinned certificate or has no private key." local id path kind options while IFS=$'\t' read -r id path kind; do options=() case "$kind" in helper) options=(--options runtime) ;; app) options=(--options runtime --entitlements "$entitlements") ;; esac codesign --force --sign "$fp" --keychain "$keychain" --timestamp=none \ --identifier "$id" --requirements "=designated => $(requirement "$id" "$fp")" \ ${options[@]+"${options[@]}"} "$path" || die "Signing $(basename "$path") failed." done <<<"$listing" verify "$app" } search_list() { security list-keychains -d user | sed -n 's/^[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' } search_list_add() { local current=() while IFS= read -r entry; do current+=("$entry"); done < <(search_list) security list-keychains -d user -s ${current[@]+"${current[@]}"} "$1" } cleanup() { local work="$1" keychain="$2" remaining=() if [[ -e "$keychain" ]]; then security delete-keychain "$keychain" >/dev/null 2>&1 || true fi while IFS= read -r entry; do [[ "$entry" == "$keychain" ]] || remaining+=("$entry") done < <(search_list) security list-keychains -d user -s ${remaining[@]+"${remaining[@]}"} || true rm -rf "$work" } [[ "$(uname -s)" == Darwin ]] || die "Whisper release signing requires macOS." case "${1:-}" in sign | verify) [[ -d "${2:-}" ]] || die "Usage: $0 sign|verify /path/to/Whisper.app" "$1" "$(cd "$2" && pwd -P)" ;; *) die "Usage: $0 sign|verify /path/to/Whisper.app" ;; esac