Wire Apple KMS release signing
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 11m0s
Build Site / Next.js Build (push) Failing after 5s
Lint Governance / BEP Metadata (push) Successful in 1s

This commit is contained in:
Conrad Kramer 2026-06-07 07:28:48 -07:00
parent ed73d7d16a
commit 126af6b5cf
17 changed files with 1913 additions and 16 deletions

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Extract signing entitlements from an Apple provisioning profile."""
from __future__ import annotations
import argparse
import plistlib
import subprocess
import sys
from pathlib import Path
def decode_profile(path: Path) -> dict:
commands = [
["security", "cms", "-D", "-i", str(path)],
["openssl", "smime", "-inform", "DER", "-verify", "-noverify", "-in", str(path)],
["openssl", "cms", "-inform", "DER", "-verify", "-noverify", "-in", str(path)],
]
errors: list[str] = []
for command in commands:
try:
proc = subprocess.run(
command,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
continue
if proc.returncode == 0 and proc.stdout:
return plistlib.loads(proc.stdout)
errors.append(f"{command[0]} exited {proc.returncode}: {proc.stderr.decode(errors='replace').strip()}")
raise RuntimeError(f"unable to decode provisioning profile {path}: {'; '.join(errors)}")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("profile", type=Path)
parser.add_argument("--output", "-o", type=Path, required=True)
args = parser.parse_args(argv)
profile = decode_profile(args.profile)
entitlements = profile.get("Entitlements")
if not isinstance(entitlements, dict):
raise RuntimeError(f"profile has no Entitlements dictionary: {args.profile}")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(plistlib.dumps(entitlements, fmt=plistlib.FMT_XML))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,258 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
apple_root="${out_root}/apple"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}"
network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}"
notarize_macos="${BURROW_NOTARIZE_MACOS:-false}"
sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-true}"
truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
safe_profile_slug() {
python3 - "$1" <<'PY'
import re
import sys
print(re.sub(r"[^A-Za-z0-9]", "", sys.argv[1]).lower())
PY
}
profile_for() {
local identifier="$1"
local suffix="$2"
local slug path
slug="$(safe_profile_slug "$identifier")"
for path in \
".forgejo/actions/export/${slug}${suffix}.provisionprofile" \
".forgejo/actions/export/${slug}${suffix}.mobileprovision" \
"Apple/Profiles/${slug}${suffix}.provisionprofile" \
"Apple/Profiles/${slug}${suffix}.mobileprovision"; do
if [[ -s "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
return 1
}
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: missing required tool: $1" >&2
exit 1
fi
}
extract_profile_entitlements() {
local profile="$1"
local output="$2"
Scripts/apple/profile-entitlements.py "$profile" --output "$output"
}
sign_bundle() {
local family="$1"
local bundle_path="$2"
local entitlements="$3"
local runtime_flag=()
if [[ "$family" == "developer-id" ]]; then
runtime_flag=(--runtime)
fi
Scripts/apple/sign-with-google-kms.sh \
--family "$family" \
--path "$bundle_path" \
--entitlements "$entitlements" \
"${runtime_flag[@]}"
}
write_notary_api_key_json() {
local out="$1"
if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || -z "${ASC_API_KEY_PATH:-}" ]]; then
echo "macOS notarization requires ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH" >&2
exit 1
fi
rcodesign encode-app-store-connect-api-key \
"$ASC_API_ISSUER_ID" \
"$ASC_API_KEY_ID" \
"$ASC_API_KEY_PATH" \
"$out"
}
sign_ios() {
local unsigned_ipa app_profile ext_profile work_dir app ext app_entitlements ext_entitlements output_ipa
unsigned_ipa="${apple_root}/Burrow-iOS-${build_number}.unsigned.ipa"
if [[ ! -s "$unsigned_ipa" ]]; then
echo "error: missing staged unsigned iOS IPA: $unsigned_ipa" >&2
exit 1
fi
app_profile="$(profile_for "$bundle_id" "appstore")" || {
echo "error: missing iOS App Store provisioning profile for ${bundle_id}" >&2
exit 1
}
ext_profile="$(profile_for "$network_extension_bundle_id" "appstore")" || {
echo "error: missing iOS App Store provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-ios-kms-sign.XXXXXX")"
trap 'rm -rf "${work_dir:-}"' RETURN
unzip -q "$unsigned_ipa" -d "$work_dir"
app="${work_dir}/Payload/Burrow.app"
ext="${app}/PlugIns/BurrowNetworkExtension.appex"
if [[ ! -d "$app" || ! -d "$ext" ]]; then
echo "error: unsigned IPA is missing Burrow.app or BurrowNetworkExtension.appex" >&2
exit 1
fi
cp "$app_profile" "${app}/embedded.mobileprovision"
cp "$ext_profile" "${ext}/embedded.mobileprovision"
app_entitlements="${work_dir}/app-entitlements.plist"
ext_entitlements="${work_dir}/extension-entitlements.plist"
extract_profile_entitlements "$app_profile" "$app_entitlements"
extract_profile_entitlements "$ext_profile" "$ext_entitlements"
rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature"
sign_bundle ios "$ext" "$ext_entitlements"
sign_bundle ios "$app" "$app_entitlements"
output_ipa="${apple_root}/Burrow-iOS-${build_number}.ipa"
rm -f "$output_ipa"
(cd "$work_dir" && zip -qry "$output_ipa" Payload)
sha256_file "$output_ipa"
}
write_sparkle_appcast() {
local artifact="$1"
local channel sparkle_dir length pubdate url
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
sparkle_dir="${out_root}/sparkle/${channel}"
mkdir -p "$sparkle_dir"
cp "$artifact" "$sparkle_dir/"
length="$(wc -c < "$artifact" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$artifact")"
cat > "${sparkle_dir}/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
if truthy "$sparkle_sign_with_kms"; then
Scripts/sparkle/sign-appcast-kms.py \
--appcast "${sparkle_dir}/appcast.xml" \
--artifact-dir "$sparkle_dir"
fi
}
sign_macos() {
local unsigned_zip app_profile ext_profile work_dir app ext app_entitlements ext_entitlements output_zip notary_zip key_json
unsigned_zip="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
if [[ ! -s "$unsigned_zip" ]]; then
echo "error: missing staged unsigned macOS ZIP: $unsigned_zip" >&2
exit 1
fi
app_profile="$(profile_for "$bundle_id" "developerid")" || {
echo "error: missing macOS Developer ID provisioning profile for ${bundle_id}" >&2
exit 1
}
ext_profile="$(profile_for "$network_extension_bundle_id" "developerid")" || {
echo "error: missing macOS Developer ID provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-macos-kms-sign.XXXXXX")"
trap 'rm -rf "${work_dir:-}"' RETURN
unzip -q "$unsigned_zip" -d "$work_dir"
app="$(find "$work_dir" -maxdepth 2 -type d -name 'Burrow.app' -print | head -n 1)"
if [[ -z "$app" || ! -d "$app" ]]; then
echo "error: unsigned macOS ZIP does not contain Burrow.app" >&2
exit 1
fi
ext="${app}/Contents/PlugIns/BurrowNetworkExtension.appex"
cp "$app_profile" "${app}/Contents/embedded.provisionprofile"
app_entitlements="${work_dir}/app-entitlements.plist"
extract_profile_entitlements "$app_profile" "$app_entitlements"
if [[ -d "$ext" ]]; then
cp "$ext_profile" "${ext}/Contents/embedded.provisionprofile"
ext_entitlements="${work_dir}/extension-entitlements.plist"
extract_profile_entitlements "$ext_profile" "$ext_entitlements"
rm -rf "${ext}/Contents/_CodeSignature"
sign_bundle developer-id "$ext" "$ext_entitlements"
fi
rm -rf "${app}/Contents/_CodeSignature"
sign_bundle developer-id "$app" "$app_entitlements"
if truthy "$notarize_macos"; then
key_json="${work_dir}/notary-api-key.json"
notary_zip="${work_dir}/Burrow.notary.zip"
write_notary_api_key_json "$key_json"
(cd "$(dirname "$app")" && zip -qry "$notary_zip" "$(basename "$app")")
rcodesign notary-submit --api-key-file "$key_json" --wait "$notary_zip"
rcodesign staple "$app"
fi
output_zip="${apple_root}/Burrow-macOS-${build_number}.zip"
rm -f "$output_zip"
(cd "$(dirname "$app")" && zip -qry "$output_zip" "$(basename "$app")")
sha256_file "$output_zip"
write_sparkle_appcast "$output_zip"
}
require_tool python3
require_tool unzip
require_tool zip
require_tool rcodesign
require_tool gcloud
mkdir -p "$apple_root"
case "$platform" in
all)
sign_ios
sign_macos
;;
ios)
sign_ios
;;
macos)
sign_macos
;;
*)
echo "unknown Apple KMS signing platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort

View file

@ -0,0 +1,263 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
family=""
path=""
entitlements=""
certificate=""
kms_key=""
kms_version=""
runtime=false
timestamp_url=""
usage() {
cat <<'EOF'
Usage: Scripts/apple/sign-with-google-kms.sh --family ios|developer-id --path <bundle-or-dmg> [options]
Options:
--entitlements <plist> XML entitlements for bundle signing
--certificate <cer> Apple public certificate matching the KMS key
--kms-key <name> Google KMS crypto key name/resource
--kms-version <version> Google KMS crypto key version
--runtime Set hardened-runtime code-signature flags
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--family)
family="${2:?missing value for --family}"
shift 2
;;
--path)
path="${2:?missing value for --path}"
shift 2
;;
--entitlements)
entitlements="${2:?missing value for --entitlements}"
shift 2
;;
--certificate)
certificate="${2:?missing value for --certificate}"
shift 2
;;
--kms-key)
kms_key="${2:?missing value for --kms-key}"
shift 2
;;
--kms-version)
kms_version="${2:?missing value for --kms-version}"
shift 2
;;
--runtime)
runtime=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument $1" >&2
usage >&2
exit 64
;;
esac
done
case "$family" in
ios)
certificate="${certificate:-Apple/Certificates/ios-distribution-3G42677598.cer}"
kms_key="${kms_key:-${BURROW_IOS_DISTRIBUTION_KMS_KEY:-apple-ios-distribution}}"
kms_version="${kms_version:-${BURROW_IOS_DISTRIBUTION_KMS_VERSION:-1}}"
timestamp_url="${BURROW_IOS_CODESIGN_TIMESTAMP_URL:-none}"
;;
developer-id)
certificate="${certificate:-Apple/Certificates/developer-id-application-9JKN6HXBHC.cer}"
kms_key="${kms_key:-${BURROW_DEVELOPER_ID_KMS_KEY:-apple-developer-id-application}}"
kms_version="${kms_version:-${BURROW_DEVELOPER_ID_KMS_VERSION:-1}}"
timestamp_url="${BURROW_DEVELOPER_ID_TIMESTAMP_URL:-${BURROW_APPLE_TIMESTAMP_URL:-}}"
;;
*)
echo "error: --family must be ios or developer-id" >&2
exit 64
;;
esac
if [[ -z "$path" ]]; then
echo "error: --path is required" >&2
exit 64
fi
if [[ "$path" != /* ]]; then
path="$repo_root/$path"
fi
if [[ "$certificate" != /* ]]; then
certificate="$repo_root/$certificate"
fi
if [[ -n "$entitlements" && "$entitlements" != /* ]]; then
entitlements="$repo_root/$entitlements"
fi
if [[ ! -e "$path" ]]; then
echo "error: signing target does not exist: $path" >&2
exit 1
fi
if [[ ! -s "$certificate" ]]; then
echo "error: Apple certificate is missing or empty: $certificate" >&2
exit 1
fi
if [[ -n "$entitlements" && ! -s "$entitlements" ]]; then
echo "error: entitlements file is missing or empty: $entitlements" >&2
exit 1
fi
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: missing required tool: $1" >&2
exit 1
fi
}
resolve_module() {
local candidate
for candidate in \
"${BURROW_APPLE_PKCS11_MODULE:-}" \
"${BURROW_GCP_KMS_PKCS11_MODULE:-}" \
"${BURROW_PKCS11_MODULE:-}" \
"$(command -v google-kms-pkcs11-module >/dev/null 2>&1 && google-kms-pkcs11-module || true)" \
/nix/store/*-google-kms-pkcs11-*/lib/libkmsp11.so \
/nix/store/*-google-kms-pkcs11-*/lib/libkmsp11.dylib; do
if [[ -n "$candidate" && -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
cert_public_key() {
local cert_file="$1"
local out="$2"
if ! openssl x509 -inform DER -in "$cert_file" -pubkey -noout >"$out" 2>/dev/null; then
openssl x509 -in "$cert_file" -pubkey -noout >"$out"
fi
}
public_fingerprint() {
openssl pkey -pubin -in "$1" -outform DER \
| openssl dgst -sha256 -binary \
| od -An -tx1 \
| tr -d ' \n'
}
resolve_kms_parts() {
local resource="$1"
local key_ring="${BURROW_GCP_KMS_KEY_RING:-${GOOGLE_KMS_KEY_RING:-projects/${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}/locations/${BURROW_KMS_LOCATION:-global}/keyRings/${BURROW_KMS_KEYRING:-burrow-identity}}}"
if [[ "$resource" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)/cryptoKeyVersions/([^/]+)$ ]]; then
printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}"
return 0
fi
if [[ "$resource" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)$ ]]; then
printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" "$kms_version"
return 0
fi
if [[ ! "$key_ring" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)$ ]]; then
echo "error: invalid KMS key ring resource: $key_ring" >&2
exit 1
fi
printf '%s\n%s\n%s\n%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "$resource" "$kms_version"
}
verify_certificate_matches_kms() {
local work_dir="$1"
local cert_public="$work_dir/apple-public.pem"
local kms_public="$work_dir/kms-public.pem"
local project location keyring key version expected actual
kms_info="$(resolve_kms_parts "$kms_key")"
project="$(printf '%s\n' "$kms_info" | sed -n '1p')"
location="$(printf '%s\n' "$kms_info" | sed -n '2p')"
keyring="$(printf '%s\n' "$kms_info" | sed -n '3p')"
key="$(printf '%s\n' "$kms_info" | sed -n '4p')"
version="$(printf '%s\n' "$kms_info" | sed -n '5p')"
if [[ -z "$version" ]]; then
echo "error: KMS key version is required for Apple release signing" >&2
exit 1
fi
cert_public_key "$certificate" "$cert_public"
expected="$(public_fingerprint "$cert_public")"
gcloud kms keys versions get-public-key "$version" \
--project="$project" \
--location="$location" \
--keyring="$keyring" \
--key="$key" \
--output-file="$kms_public" >/dev/null
actual="$(public_fingerprint "$kms_public")"
if [[ "$actual" != "$expected" ]]; then
echo "error: Apple certificate public key does not match Google KMS key" >&2
echo "family=$family" >&2
echo "kms_key=$key" >&2
echo "kms_version=$version" >&2
echo "certificate_spki_sha256=$expected" >&2
echo "kms_spki_sha256=$actual" >&2
exit 1
fi
}
require_tool rcodesign
require_tool openssl
require_tool od
require_tool gcloud
if ! rcodesign sign --help 2>&1 | grep -Fq -- "--pkcs11-library"; then
echo "error: rcodesign does not include PKCS#11 signing support" >&2
exit 1
fi
module="$(resolve_module || true)"
if [[ -z "$module" ]]; then
echo "error: unable to locate Google KMS PKCS#11 module" >&2
exit 1
fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-apple-kms-sign.XXXXXX")"
trap 'rm -rf "$work_dir"' EXIT
eval "$(Scripts/release/google-kms-pkcs11-materialize.sh --emit-env)"
verify_certificate_matches_kms "$work_dir"
real_openssl="$(command -v openssl)"
export BURROW_REAL_OPENSSL="${BURROW_REAL_OPENSSL:-$real_openssl}"
export BURROW_OPENSSL="$repo_root/Scripts/release/google-kms-openssl-dgst-shim.sh"
export BURROW_APPLE_PKCS11_BACKEND=gcp-kms
export BURROW_APPLE_PKCS11_SIGN_WITH_OPENSSL_PROVIDER=true
export BURROW_APPLE_PKCS11_SIGN_WITH_GCLOUD=true
export BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY="$kms_key"
export BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION="$kms_version"
pin="${BURROW_APPLE_PKCS11_PIN:-${BURROW_GCP_KMS_PKCS11_PIN:-${BURROW_PKCS11_PIN:-ignored}}}"
token_label="${BURROW_APPLE_PKCS11_TOKEN_LABEL:-${BURROW_GCP_KMS_PKCS11_TOKEN_LABEL:-${BURROW_PKCS11_TOKEN_LABEL:-burrow-release}}}"
args=(
sign
--pkcs11-library "$module"
--pkcs11-certificate-file "$certificate"
--pkcs11-token-label "$token_label"
--pkcs11-key-label "$kms_key"
--pkcs11-pin "$pin"
)
if [[ -n "$timestamp_url" ]]; then
args+=(--timestamp-url "$timestamp_url")
fi
if [[ -n "$entitlements" ]]; then
args+=(--entitlements-xml-file "$entitlements")
fi
if [[ "$runtime" == "true" ]]; then
args+=(--code-signature-flags runtime)
fi
args+=("$path")
rcodesign "${args[@]}"

View file

@ -261,7 +261,7 @@ async function main() {
const issuerId = args["issuer-id"];
const keyFile = args["key-file"];
const outDir = args["out-dir"] ?? ".forgejo/actions/export";
const appId = args["app-id"] ?? "com.hackclub.burrow";
const appId = args["app-id"] ?? "net.burrow.app";
const networkId = args["network-id"] ?? `${appId}.network`;
const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID;
const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID;