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

@ -72,9 +72,12 @@ env:
BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }}
BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }}
BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }}
BURROW_DEVELOPER_ID_CERTIFICATE_ID: ${{ vars.BURROW_DEVELOPER_ID_CERTIFICATE_ID || '9JKN6HXBHC' }}
BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS: ${{ vars.BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS || 'false' }}
BURROW_APPLE_ENABLE_CAPABILITIES: ${{ vars.BURROW_APPLE_ENABLE_CAPABILITIES || 'false' }}
BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'false' }}
BURROW_APPLE_SIGNING_MODE: ${{ vars.BURROW_APPLE_SIGNING_MODE || 'google-kms-staged' }}
BURROW_NOTARIZE_MACOS: ${{ vars.BURROW_NOTARIZE_MACOS || 'true' }}
BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'true' }}
BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }}
BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }}
BURROW_SPARKLE_FEED_URL: ${{ vars.BURROW_SPARKLE_FEED_URL || 'https://releases.burrow.net/sparkle/appcast.xml' }}
@ -228,6 +231,29 @@ jobs:
xcrun --sdk iphoneos --show-sdk-path
xcrun --sdk iphonesimulator --show-sdk-path
- name: Prepare Rust Apple Targets
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
export PATH="$HOME/.cargo/bin:$PATH"
fi
case "$PLATFORM" in
ios)
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
;;
macos)
rustup target add aarch64-apple-darwin x86_64-apple-darwin
;;
*)
echo "::error ::unsupported Apple platform ${PLATFORM}" >&2
exit 1
;;
esac
- name: Bootstrap Runner Environment
shell: bash
run: |
@ -291,6 +317,7 @@ jobs:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
PLATFORM: ${{ matrix.platform }}
UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'false' }}
UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }}
SPARKLE_CHANNEL: build-${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
@ -298,6 +325,9 @@ jobs:
if [[ "$UPLOAD_APP_STORE" == "true" && "$PLATFORM" == "ios" ]]; then
require_signing=true
fi
if [[ "$UPLOAD_SPARKLE" == "true" && "$PLATFORM" == "macos" ]]; then
require_signing=true
fi
bazel_args=()
if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" && -f "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then
@ -312,10 +342,12 @@ jobs:
--action_env=BUILD_NUMBER="$BUILD_NUMBER" \
--action_env=BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" \
--action_env=BURROW_APPLE_SIGNING_READY="${BURROW_APPLE_SIGNING_READY:-0}" \
--action_env=BURROW_APPLE_SIGNING_MODE="${BURROW_APPLE_SIGNING_MODE:-keychain}" \
--action_env=BURROW_APPLE_REQUIRE_SIGNING="$require_signing" \
--action_env=BURROW_APPLE_BUNDLE_ID="${BURROW_APPLE_BUNDLE_ID:-}" \
--action_env=BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-}" \
--action_env=BURROW_APPLE_KEYCHAIN_PATH="${BURROW_APPLE_KEYCHAIN_PATH:-}" \
--action_env=BURROW_NOTARIZE_MACOS="${BURROW_NOTARIZE_MACOS:-false}" \
--action_env=BURROW_SPARKLE_SIGN_WITH_KMS="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}" \
--action_env=BURROW_SPARKLE_KMS_KEY="${BURROW_SPARKLE_KMS_KEY:-sparkle-ed25519}" \
--action_env=BURROW_SPARKLE_KMS_VERSION="${BURROW_SPARKLE_KMS_VERSION:-1}" \

View file

@ -62,10 +62,83 @@ env:
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }}
BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }}
BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }}
BURROW_DEVELOPER_ID_CERTIFICATE_ID: ${{ vars.BURROW_DEVELOPER_ID_CERTIFICATE_ID || '9JKN6HXBHC' }}
BURROW_APPLE_SIGNING_MODE: ${{ vars.BURROW_APPLE_SIGNING_MODE || 'google-kms-staged' }}
BURROW_NOTARIZE_MACOS: ${{ vars.BURROW_NOTARIZE_MACOS || 'true' }}
BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'true' }}
BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }}
BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }}
BURROW_KMS_LOCATION: ${{ vars.BURROW_KMS_LOCATION || 'global' }}
BURROW_KMS_KEYRING: ${{ vars.BURROW_KMS_KEYRING || 'burrow-identity' }}
jobs:
sign-apple-kms:
name: Sign (Apple KMS)
if: ${{ github.event.inputs.upload_app_store == 'true' || github.event.inputs.upload_sparkle == 'true' }}
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Configure Age
shell: bash
env:
AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }}
run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV"
- name: Decrypt Release Secrets
uses: ./.forgejo/actions/decrypt-release-secrets
- name: Validate App Store Credentials
shell: bash
run: Scripts/ci/check-release-config.sh app-store
- name: Download Staged Apple Artifacts
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
run: |
set -euo pipefail
nix develop .#ci -c Scripts/ci/google-wif-auth.sh
mkdir -p "dist/builds/${BUILD_NUMBER}/apple"
nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/apple" "dist/builds/${BUILD_NUMBER}/apple"
find "dist/builds/${BUILD_NUMBER}/apple" -type f -print | sort
- name: Sync Apple Provisioning Profiles
shell: bash
run: nix develop .#ci -c Scripts/ci/sync-apple-provisioning-profiles.sh all
- name: Sign Apple Artifacts With KMS
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
SPARKLE_CHANNEL_INPUT: ${{ github.event.inputs.sparkle_channel }}
run: |
set -euo pipefail
channel="${SPARKLE_CHANNEL_INPUT:-}"
if [[ -z "$channel" ]]; then
channel="build-${BUILD_NUMBER}"
fi
SPARKLE_CHANNEL="$channel" nix develop .#ci -c Scripts/apple/sign-release-artifacts-kms.sh all
- name: Upload Signed Apple Artifacts
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
run: nix develop .#ci -c Scripts/ci/upload-release-storage.sh
upload-app-store:
name: Upload (App Store Connect)
needs: sign-apple-kms
if: ${{ github.event.inputs.upload_app_store == 'true' }}
runs-on: namespace-profile-macos-large
steps:
@ -120,7 +193,11 @@ jobs:
cp "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8"
chmod 600 "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8"
shopt -s nullglob
artifacts=(publish/apple/*.ipa publish/apple/*.pkg)
artifacts=()
for artifact in publish/apple/*.ipa publish/apple/*.pkg; do
[[ "$artifact" == *.unsigned.ipa ]] && continue
artifacts+=("$artifact")
done
if (( ${#artifacts[@]} == 0 )); then
echo "::error ::No Apple upload artifacts found for build ${{ github.event.inputs.build_number }}."
exit 1
@ -219,6 +296,7 @@ jobs:
upload-sparkle:
name: Upload (Sparkle)
needs: sign-apple-kms
if: ${{ github.event.inputs.upload_sparkle == 'true' }}
runs-on: namespace-profile-linux-medium
steps:

View file

@ -45,6 +45,27 @@ for ARCH in "${BURROW_ARCHS[@]}"; do
esac
done
if [[ -x "$(command -v rustup)" ]]; then
INSTALLED_TARGETS="$(rustup target list --installed)"
MISSING_TARGETS=()
for TARGET in "${RUST_TARGETS[@]}"; do
if ! grep -qx "$TARGET" <<< "$INSTALLED_TARGETS"; then
MISSING_TARGETS+=("$TARGET")
fi
done
if (( ${#MISSING_TARGETS[@]} > 0 )); then
rustup target add "${MISSING_TARGETS[@]}"
fi
else
SYSROOT="$(rustc --print sysroot)"
for TARGET in "${RUST_TARGETS[@]}"; do
if [[ ! -d "${SYSROOT}/lib/rustlib/${TARGET}" ]]; then
echo "error: Rust target ${TARGET} is not installed and rustup is unavailable" >&2
exit 1
fi
done
fi
# Pass all RUST_TARGETS in a single invocation
CARGO_ARGS=()
for TARGET in "${RUST_TARGETS[@]}"; do

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;

View file

@ -13,13 +13,15 @@ derived_data="${BURROW_DERIVED_DATA_PATH:-${repo_root}/.derived-data/apple}"
source_packages="${BURROW_SWIFTPM_CACHE_PATH:-${XDG_CACHE_HOME:-${HOME}/.cache}/burrow/swiftpm}"
project="Apple/Burrow.xcodeproj"
scheme="${BURROW_APPLE_SCHEME:-App}"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}"
network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}"
signing_ready="${BURROW_APPLE_SIGNING_READY:-0}"
signing_mode="${BURROW_APPLE_SIGNING_MODE:-keychain}"
require_signing="${BURROW_APPLE_REQUIRE_SIGNING:-false}"
sparkle_feed_url="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}"
sparkle_public_ed_key="${BURROW_SPARKLE_PUBLIC_ED_KEY:-Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=}"
sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}"
notarize_macos="${BURROW_NOTARIZE_MACOS:-false}"
mkdir -p "$apple_root" "$archives_root" "$derived_data" "$source_packages"
@ -39,6 +41,74 @@ sha256_file() {
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
}
generate_entitlements_from_profile() {
local profile="$1"
local output="$2"
Scripts/apple/profile-entitlements.py "$profile" --output "$output"
}
apple_signing_available() {
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "google-kms-staged" || "$signing_mode" == "kms" ]]; then
return 0
fi
[[ "$signing_ready" == "1" ]]
}
kms_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[@]}"
}
kms_sign_file() {
local family="$1"
local file_path="$2"
local runtime_flag=()
if [[ "$family" == "developer-id" ]]; then
runtime_flag=(--runtime)
fi
Scripts/apple/sign-with-google-kms.sh \
--family "$family" \
--path "$file_path" \
"${runtime_flag[@]}"
}
write_version() {
mkdir -p Apple/Configuration
printf 'CURRENT_PROJECT_VERSION = %s\n' "$build_number" > Apple/Configuration/Version.xcconfig
@ -93,7 +163,7 @@ unsigned_note() {
ensure_signing_or_note() {
local platform_name="$1"
local artifact_name="$2"
if [[ "$signing_ready" == "1" ]]; then
if apple_signing_available; then
return 0
fi
if truthy "$require_signing"; then
@ -151,6 +221,14 @@ build_ios_release() {
build_ios_unsigned
return 0
fi
if [[ "$signing_mode" == "google-kms-staged" ]]; then
build_ios_kms_staged
return 0
fi
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then
build_ios_kms_release
return 0
fi
archive_path="${archives_root}/Burrow-iOS-${build_number}.xcarchive"
export_dir="${apple_root}/ios-export"
@ -181,6 +259,91 @@ build_ios_release() {
sha256_file "${apple_root}/Burrow-iOS-${build_number}.ipa"
}
build_ios_kms_staged() {
local product_dir source_app stage_dir ipa
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
SKIP_INSTALL=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release-iphoneos"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "iOS build completed but did not produce Burrow.app" >&2
exit 1
fi
stage_dir="${apple_root}/ios-unsigned/Payload"
rm -rf "$stage_dir"
mkdir -p "$stage_dir"
ditto "$source_app" "${stage_dir}/Burrow.app"
ipa="${apple_root}/Burrow-iOS-${build_number}.unsigned.ipa"
rm -f "$ipa"
(cd "${apple_root}/ios-unsigned" && zip -qry "$ipa" Payload)
sha256_file "$ipa"
}
build_ios_kms_release() {
local product_dir source_app stage_dir app ext app_profile ext_profile app_entitlements ext_entitlements ipa
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
SKIP_INSTALL=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release-iphoneos"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "iOS build completed but did not produce Burrow.app" >&2
exit 1
fi
stage_dir="${apple_root}/ios-kms/Payload"
rm -rf "$stage_dir"
mkdir -p "$stage_dir"
ditto "$source_app" "${stage_dir}/Burrow.app"
app="${stage_dir}/Burrow.app"
ext="${app}/PlugIns/BurrowNetworkExtension.appex"
if [[ ! -d "$ext" ]]; then
echo "iOS app is missing BurrowNetworkExtension.appex" >&2
exit 1
fi
app_profile="$(profile_for "$bundle_id" "appstore")" || {
echo "missing iOS App Store provisioning profile for ${bundle_id}" >&2
exit 1
}
ext_profile="$(profile_for "$network_extension_bundle_id" "appstore")" || {
echo "missing iOS App Store provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
cp "$app_profile" "${app}/embedded.mobileprovision"
cp "$ext_profile" "${ext}/embedded.mobileprovision"
app_entitlements="${apple_root}/ios-kms/app-entitlements.plist"
ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist"
generate_entitlements_from_profile "$app_profile" "$app_entitlements"
generate_entitlements_from_profile "$ext_profile" "$ext_entitlements"
rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature"
kms_sign_bundle ios "$ext" "$ext_entitlements"
kms_sign_bundle ios "$app" "$app_entitlements"
ipa="${apple_root}/Burrow-iOS-${build_number}.ipa"
rm -f "$ipa"
(cd "${apple_root}/ios-kms" && zip -qry "$ipa" Payload)
sha256_file "$ipa"
}
build_macos_unsigned() {
local product_dir zip_path
xcodebuild build \
@ -203,6 +366,14 @@ build_macos_release() {
build_macos_unsigned
return 0
fi
if [[ "$signing_mode" == "google-kms-staged" ]]; then
build_macos_kms_staged
return 0
fi
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then
build_macos_kms_release
return 0
fi
archive_path="${archives_root}/Burrow-macOS-${build_number}.xcarchive"
export_dir="${apple_root}/macos-export"
@ -264,6 +435,135 @@ EOF
fi
}
build_macos_kms_staged() {
local product_dir source_app zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "macOS build completed but did not produce Burrow.app" >&2
exit 1
fi
zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
rm -f "$zip_path"
ditto -c -k --keepParent "$source_app" "$zip_path"
sha256_file "$zip_path"
}
write_sparkle_appcast() {
local dmg="$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 "$dmg" "$sparkle_dir/"
length="$(wc -c < "$dmg" | 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 "$dmg")"
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
}
notarize_dmg_if_requested() {
local dmg="$1"
local key_json
if ! truthy "$notarize_macos"; then
return 0
fi
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
key_json="${apple_root}/notary-api-key.json"
rcodesign encode-app-store-connect-api-key \
"$ASC_API_ISSUER_ID" \
"$ASC_API_KEY_ID" \
"$ASC_API_KEY_PATH" \
"$key_json"
rcodesign notary-submit --api-key-file "$key_json" --wait --staple "$dmg"
rm -f "$key_json"
}
build_macos_kms_release() {
local product_dir source_app app ext app_profile ext_profile app_entitlements ext_entitlements dmg
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "macOS build completed but did not produce Burrow.app" >&2
exit 1
fi
app="${apple_root}/macos-kms/Burrow.app"
rm -rf "$app"
mkdir -p "$(dirname "$app")"
ditto "$source_app" "$app"
ext="${app}/Contents/PlugIns/BurrowNetworkExtension.appex"
app_profile="$(profile_for "$bundle_id" "developerid")" || {
echo "missing macOS Developer ID provisioning profile for ${bundle_id}" >&2
exit 1
}
cp "$app_profile" "${app}/Contents/embedded.provisionprofile"
app_entitlements="${apple_root}/macos-kms/app-entitlements.plist"
generate_entitlements_from_profile "$app_profile" "$app_entitlements"
if [[ -d "$ext" ]]; then
ext_profile="$(profile_for "$network_extension_bundle_id" "developerid")" || {
echo "missing macOS Developer ID provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
cp "$ext_profile" "${ext}/Contents/embedded.provisionprofile"
ext_entitlements="${apple_root}/macos-kms/extension-entitlements.plist"
generate_entitlements_from_profile "$ext_profile" "$ext_entitlements"
rm -rf "${ext}/Contents/_CodeSignature"
kms_sign_bundle developer-id "$ext" "$ext_entitlements"
fi
rm -rf "${app}/Contents/_CodeSignature"
kms_sign_bundle developer-id "$app" "$app_entitlements"
dmg="${apple_root}/Burrow-macOS-${build_number}.dmg"
rm -f "$dmg"
hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg"
kms_sign_file developer-id "$dmg"
notarize_dmg_if_requested "$dmg"
sha256_file "$dmg"
write_sparkle_appcast "$dmg"
}
require_xcodebuild
write_version

View file

@ -6,7 +6,7 @@ cd "$repo_root"
platform="${1:-all}"
out_dir="${BURROW_APPLE_PROFILES_OUT_DIR:-.forgejo/actions/export}"
app_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
app_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}"
network_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${app_id}.network}"
replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-false}"
if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" || -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then

View file

@ -0,0 +1,197 @@
#!/usr/bin/env bash
set -euo pipefail
real_openssl="${BURROW_REAL_OPENSSL:-openssl}"
gcloud_bin="${BURROW_GCLOUD_BIN:-gcloud}"
original_args=("$@")
exec_real_openssl() {
exec "$real_openssl" "$@"
}
truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
parse_pkcs11_key_name() {
local uri="$1"
local value
value="${uri#*;object=}"
if [[ "$value" != "$uri" ]]; then
printf '%s\n' "${value%%;*}"
return 0
fi
value="${uri#*;id=}"
if [[ "$value" != "$uri" ]]; then
printf '%s\n' "${value%%;*}"
return 0
fi
return 1
}
select_single_enabled_version() {
local project_id="$1"
local kms_location="$2"
local kms_keyring_name="$3"
local crypto_key_name="$4"
local versions=()
local version
while IFS= read -r version; do
[[ -n "$version" ]] && versions+=("$version")
done < <(
"$gcloud_bin" kms keys versions list \
--project="$project_id" \
--location="$kms_location" \
--keyring="$kms_keyring_name" \
--key="$crypto_key_name" \
--filter=state=ENABLED \
--format='value(name)'
)
if [[ "${#versions[@]}" -ne 1 || -z "${versions[0]:-}" ]]; then
echo "error: Google KMS crypto key ${crypto_key_name} has ${#versions[@]} ENABLED versions; set BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION" >&2
return 1
fi
printf '%s\n' "${versions[0]##*/}"
}
if [[ "${1:-}" != "dgst" ]]; then
exec_real_openssl "${original_args[@]}"
fi
if ! truthy "${BURROW_APPLE_PKCS11_SIGN_WITH_GCLOUD:-true}"; then
exec_real_openssl "${original_args[@]}"
fi
shift
digest_algorithm=""
signature_file=""
input_file=""
sign_uri=""
while [[ $# -gt 0 ]]; do
case "$1" in
-sha256)
digest_algorithm="sha256"
shift
;;
-sign)
[[ $# -ge 2 ]] || {
echo "error: openssl dgst shim received -sign without a key URI" >&2
exit 2
}
sign_uri="$2"
shift 2
;;
-out)
[[ $# -ge 2 ]] || {
echo "error: openssl dgst shim received -out without a path" >&2
exit 2
}
signature_file="$2"
shift 2
;;
-provider|-passin)
[[ $# -ge 2 ]] || {
echo "error: openssl dgst shim received $1 without a value" >&2
exit 2
}
shift 2
;;
-*)
exec_real_openssl "${original_args[@]}"
;;
*)
input_file="$1"
shift
;;
esac
done
if [[ "$digest_algorithm" != "sha256" || "$sign_uri" != pkcs11:* ]]; then
exec_real_openssl "${original_args[@]}"
fi
if [[ -z "$signature_file" || -z "$input_file" ]]; then
echo "error: openssl dgst shim requires -out and an input file" >&2
exit 2
fi
if [[ ! -f "$input_file" ]]; then
echo "error: openssl dgst shim input file does not exist: $input_file" >&2
exit 1
fi
command -v "$gcloud_bin" >/dev/null 2>&1 || {
echo "error: gcloud is required for Google KMS direct signing; set BURROW_GCLOUD_BIN" >&2
exit 1
}
command -v python3 >/dev/null 2>&1 || {
echo "error: python3 is required to decode Google KMS signatures" >&2
exit 1
}
crypto_key="${BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY:-}"
if [[ -z "$crypto_key" ]]; then
crypto_key="$(parse_pkcs11_key_name "$sign_uri" || true)"
fi
if [[ -z "$crypto_key" ]]; then
echo "error: BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY or a pkcs11 object selector is required" >&2
exit 1
fi
key_version="${BURROW_GCP_KMS_ACTIVE_CRYPTO_KEY_VERSION:-}"
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}}}"
project_id="${GOOGLE_CLOUD_PROJECT:-project-88c23ce9-918a-470a-b33}"
kms_location=""
kms_keyring_name=""
crypto_key_name=""
if [[ "$crypto_key" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)/cryptoKeyVersions/([^/]+)$ ]]; then
project_id="${BASH_REMATCH[1]}"
kms_location="${BASH_REMATCH[2]}"
kms_keyring_name="${BASH_REMATCH[3]}"
crypto_key_name="${BASH_REMATCH[4]}"
key_version="${BASH_REMATCH[5]}"
elif [[ "$crypto_key" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)$ ]]; then
project_id="${BASH_REMATCH[1]}"
kms_location="${BASH_REMATCH[2]}"
kms_keyring_name="${BASH_REMATCH[3]}"
crypto_key_name="${BASH_REMATCH[4]}"
else
if [[ ! "$key_ring" =~ ^projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)$ ]]; then
echo "error: BURROW_GCP_KMS_KEY_RING must be projects/<project>/locations/<location>/keyRings/<name>, got: ${key_ring}" >&2
exit 1
fi
project_id="${BASH_REMATCH[1]}"
kms_location="${BASH_REMATCH[2]}"
kms_keyring_name="${BASH_REMATCH[3]}"
crypto_key_name="$crypto_key"
fi
if [[ -z "$key_version" ]]; then
key_version="$(select_single_enabled_version "$project_id" "$kms_location" "$kms_keyring_name" "$crypto_key_name")"
fi
encoded_signature_file="$(mktemp "${TMPDIR:-/tmp}/burrow-google-kms-signature.XXXXXX")"
trap 'rm -f "$encoded_signature_file"' EXIT
"$gcloud_bin" kms asymmetric-sign \
--project="$project_id" \
--location="$kms_location" \
--keyring="$kms_keyring_name" \
--key="$crypto_key_name" \
--version="$key_version" \
--digest-algorithm="$digest_algorithm" \
--input-file="$input_file" \
--signature-file="$encoded_signature_file" \
--quiet >/dev/null
python3 - "$encoded_signature_file" "$signature_file" <<'PY'
import base64
import pathlib
import sys
encoded_path = pathlib.Path(sys.argv[1])
signature_path = pathlib.Path(sys.argv[2])
signature_path.write_bytes(base64.b64decode(encoded_path.read_text(encoding="utf-8").strip()))
PY

View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
emit_env=false
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
pwd
}
)"
runtime_dir="${BURROW_GCP_KMS_PKCS11_RUNTIME_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-google-kms-pkcs11-runtime}"
config_file="${BURROW_GCP_KMS_PKCS11_CONFIG:-${KMS_PKCS11_CONFIG:-}}"
config_yaml="${BURROW_GCP_KMS_PKCS11_CONFIG_YAML:-}"
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}}}"
token_label="${BURROW_GCP_KMS_PKCS11_TOKEN_LABEL:-${BURROW_PKCS11_TOKEN_LABEL:-burrow-release}}"
usage() {
cat <<'EOF'
Usage: Scripts/release/google-kms-pkcs11-materialize.sh [--emit-env]
Materializes optional Google KMS PKCS#11 config YAML into a runner-local file
and emits environment variables consumed by the Apple release signer.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--emit-env)
emit_env=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument $1" >&2
usage >&2
exit 2
;;
esac
done
write_config_file() {
local path="$1"
local value="$2"
mkdir -p "$(dirname "$path")"
printf '%s\n' "$value" >"$path"
chmod 600 "$path"
}
resolve_config_path() {
local path="$1"
if [[ -z "$path" ]]; then
return 1
fi
if [[ "$path" != /* ]]; then
path="$ROOT/$path"
fi
printf '%s\n' "$path"
}
env_lines=()
if [[ -z "$config_file" && -n "$config_yaml" ]]; then
config_file="$runtime_dir/pkcs11-config.yaml"
write_config_file "$config_file" "$config_yaml"
fi
if [[ -z "$config_file" && -n "$key_ring" ]]; then
config_file="$runtime_dir/pkcs11-config.yaml"
write_config_file "$config_file" "---
tokens:
- key_ring: \"${key_ring}\"
label: \"${token_label}\""
fi
if [[ -n "$config_file" ]]; then
config_file="$(resolve_config_path "$config_file")"
if [[ ! -s "$config_file" ]]; then
echo "error: Google KMS PKCS#11 config is missing or empty: $config_file" >&2
exit 1
fi
env_lines+=("BURROW_GCP_KMS_PKCS11_CONFIG=$config_file")
env_lines+=("KMS_PKCS11_CONFIG=$config_file")
fi
if [[ "${#env_lines[@]}" -gt 0 && -n "${GITHUB_ENV:-}" ]]; then
printf '%s\n' "${env_lines[@]}" >>"$GITHUB_ENV"
fi
if [[ "$emit_env" == "true" && "${#env_lines[@]}" -gt 0 ]]; then
printf 'export %q\n' "${env_lines[@]}"
fi

View file

@ -2,7 +2,7 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-net.burrow.app}"
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
simulator_id="${BURROW_UI_TEST_SIMULATOR_ID:-}"

View file

@ -2,7 +2,7 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-net.burrow.app}"
smoke_root="${BURROW_TAILNET_SMOKE_ROOT:-/tmp/burrow-tailnet-connectivity}"
socket_path="${smoke_root}/burrow.sock"
db_path="${smoke_root}/burrow.db"

View file

@ -49,7 +49,7 @@ def kms_sign(args: argparse.Namespace, payload: Path) -> bytes:
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
return signature_path.read_bytes()
return base64.b64decode(signature_path.read_text(encoding="utf-8").strip())
def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path:

View file

@ -47,7 +47,7 @@ The same change also makes the forge itself the release authority: release tags
- `//bazel/apple:release_macos_stamp`
These targets wrap the existing Xcode project and give the workflow one stable entrypoint that shares the same Bazel/Nix cache setup while Burrow decides whether deeper `rules_apple` targets are worth the migration.
- Use Namespace cache setup before Bazel/Nix work. Prefer the Namespace Bazel remote cache when `nsc` is authenticated and fall back to a local disk/repository cache when remote setup is unavailable.
- Produce unsigned Apple validation artifacts when signing is absent, but require signing for an App Store requested iOS release. Uploadable artifacts are produced only when signing assets are installed and provisioning profiles can be synced from App Store Connect credentials.
- Produce unsigned Apple validation artifacts when signing is absent, but require signing for an App Store requested iOS release and for a Sparkle tester release. Uploadable artifacts are produced only after provisioning profiles can be synced from App Store Connect credentials and the relevant Apple certificate is proven to match the selected Google KMS key.
- Resolve release credentials through age/agenix before signed Apple lanes run. Forgejo secrets should carry only the runner age identity fallback and the Authentik WIF client secret until OpenBao can mint the runner token directly. App Store Connect keys and signing certificates live as sealed files under `secrets/`.
- Persist the forge runner SSH key as `/var/lib/forgejo-runner-agent/age_keystore` and export `BURROW_RUNNER_AGE_IDENTITY_PATH` so jobs can resolve agenix identities without embedding long-lived material in every workflow.
- Add `Scripts/forgejo-dispatch.sh` and `Scripts/forgejo-dispatch-via-host.sh`:
@ -58,7 +58,7 @@ The same change also makes the forge itself the release authority: release tags
- Keep the canonical Forgejo repo as an ordinary source repository, not a mirror. The host config disables new mirror creation and removes mirror/push-mirror rows for the canonical Burrow repository at activation time.
- Add `.forgejo/workflows/infra-opentofu.yml` and `Scripts/grafana-tofu.sh` for Grafana API-managed folders and dashboards. NixOS owns the Grafana service, SSO, reverse proxy, datasources, Prometheus, OpenTelemetry collector, Jaeger, secrets, and boot-time configuration; OpenTofu owns API-created dashboard objects.
- Sync provisioning profiles from App Store Connect in CI using the decrypted API key:
- iOS App Store profiles for `com.hackclub.burrow` and `com.hackclub.burrow.network`.
- iOS App Store profiles for `net.burrow.app` and `net.burrow.app.network`.
- macOS Developer ID profiles for the same bundle identifiers.
Provisioning profile `.age` files are not part of the normal release path.
- Add a non-exportable Google KMS RSA-2048/SHA-256 key named
@ -85,6 +85,16 @@ The same change also makes the forge itself the release authority: release tags
sync must verify App Store profiles reference that certificate. Stale profiles
with the same name may be deleted and recreated so CI does not silently sign
against an older distribution identity.
- Apple release signing uses a staged KMS flow because Google publishes the
Cloud KMS PKCS#11 library for Linux release runners, not macOS runners:
- macOS Namespace builders compile unsigned iOS device and macOS app bundles
with the existing Xcode project.
- Linux release runners download those staged artifacts, authenticate to
Google through Authentik WIF, verify the Apple `.cer` public key matches the
intended Google KMS key version, embed the synced provisioning profiles, and
sign the app/extension bundles with the patched `rcodesign` PKCS#11 path.
- The signed iOS IPA is uploaded to App Store Connect, and the signed macOS
ZIP is used as the Sparkle tester artifact.
- Add a non-exportable Google KMS key named `sparkle-ed25519` for Sparkle
appcast signatures. It uses `EC_SIGN_ED25519` with software protection level
because Google KMS rejects Ed25519 for HSM protection. macOS builds embed
@ -97,7 +107,7 @@ The same change also makes the forge itself the release authority: release tags
- Treat Sparkle as a macOS public-channel upload lane:
- Generate staged appcast content from the signed macOS artifact when present.
- Publish `sparkle/<channel>/`, `sparkle/appcast.xml`, and `sparkle/default/` pointers from `publish-store-uploads`.
- Full Sparkle client integration remains a follow-up because the current Apple app does not embed Sparkle.
- The macOS app embeds Sparkle and consumes the `https://releases.burrow.net/sparkle/appcast.xml` feed.
- Add `infra/releases` as the OpenTofu boundary for the GCS release and package repository backup buckets. Garage is the required S3-compatible primary distribution target after host bootstrap. `infra/identity` remains the boundary for Google KMS signing keys, the Authentik WIF pool/provider, and the runner service account.
## Security and Operational Considerations
@ -110,8 +120,13 @@ The same change also makes the forge itself the release authority: release tags
- rclone is not part of the release path. Multi-cloud failover should be added through provider-specific or S3-compatible wrappers with explicit credentials and evidence.
- Required upload credentials are checked explicitly after `./.forgejo/actions/decrypt-release-secrets` materializes them from age:
- `secrets/apple/appstore-connect.env.age`: `ASC_API_KEY_ID`, `ASC_API_ISSUER_ID`, and base64-encoded `.p8` key material.
- `secrets/apple/distribution-signing.env.age`: base64-encoded distribution `.p12` plus its password.
- `secrets/apple/sparkle.env.age`: base64-encoded Sparkle EdDSA private key.
- KMS-backed Apple signing must not use `.p12` private-key material. The
legacy `secrets/apple/distribution-signing.env.age` path remains accepted
only for emergency keychain signing and should not be used for the normal
tester release lane.
- Sparkle appcasts are signed with Google KMS by default; the legacy
`secrets/apple/sparkle.env.age` path remains accepted only for emergency
local EdDSA signing.
- future Microsoft Partner Center credentials
- The temporary CI keychain password is always the distribution certificate password; there is no separate keychain-password release secret.
- KMS-backed Apple certificates do not satisfy the existing Xcode/keychain

View file

@ -51,6 +51,64 @@
else
pkgs.bazel-buildtools;
optionalPackage = name: lib.optional (lib.hasAttr name pkgs) (lib.getAttr name pkgs);
googleKmsPkcs11Package =
if pkgs.stdenv.hostPlatform.isx86_64 && pkgs.stdenv.isLinux then
let
version = "1.9";
in
pkgs.stdenv.mkDerivation {
pname = "google-kms-pkcs11";
inherit version;
src = pkgs.fetchurl {
url = "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/pkcs11-v${version}/libkmsp11-${version}-linux-amd64.tar.gz";
sha256 = "sha256-U5f5ZQc81dHixD5RSPEC8xp8g1kC+X60HHycDxuHakA=";
};
nativeBuildInputs = [ pkgs.autoPatchelfHook ];
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
installPhase = ''
runHook preInstall
install -Dm755 libkmsp11.so "$out/lib/libkmsp11.so"
install -Dm644 kmsp11.h "$out/include/kmsp11.h"
install -Dm644 LICENSE "$out/share/licenses/google-kms-pkcs11/LICENSE"
mkdir -p "$out/bin"
cat > "$out/bin/google-kms-pkcs11-module" <<EOF
#!/usr/bin/env sh
printf '%s\n' "$out/lib/libkmsp11.so"
EOF
chmod 755 "$out/bin/google-kms-pkcs11-module"
runHook postInstall
'';
meta = with lib; {
description = "Google Cloud KMS PKCS#11 library";
homepage = "https://github.com/GoogleCloudPlatform/kms-integrations";
license = licenses.asl20;
platforms = [ "x86_64-linux" ];
};
}
else
null;
rcodesignPkcs11Package =
if pkgs ? rcodesign then
pkgs.rcodesign.overrideAttrs (old: {
pname = "rcodesign-pkcs11";
patches = (old.patches or [ ]) ++ [
./patches/rcodesign-pkcs11-tool.patch
];
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.makeWrapper ];
postInstall = (old.postInstall or "") + ''
wrapProgram "$out/bin/rcodesign" \
--prefix PATH : ${lib.makeBinPath [ pkgs.opensc pkgs.openssl ]} \
--set-default BURROW_OPENSSL_MODULES ${pkgs.pkcs11-provider}/lib/ossl-modules
'';
passthru = (old.passthru or { }) // {
isBurrowPkcs11Fork = true;
};
meta = (old.meta or { }) // {
description = "rcodesign with Burrow PKCS#11 signing support";
};
})
else
null;
commonPackages = with pkgs; [
cargo
rustc
@ -69,12 +127,17 @@
jq
nodejs_20
python3
openssl
rsync
zip
unzip
] ++ optionalPackage "google-cloud-sdk"
++ optionalPackage "awscli2"
++ optionalPackage "attic-client";
++ optionalPackage "attic-client"
++ optionalPackage "opensc"
++ optionalPackage "pkcs11-provider"
++ lib.optional (googleKmsPkcs11Package != null) googleKmsPkcs11Package
++ lib.optional (rcodesignPkcs11Package != null) rcodesignPkcs11Package;
linuxRepositoryPackages = lib.optionals pkgs.stdenv.isLinux (
(with pkgs; [
dpkg
@ -273,7 +336,9 @@
forgejo-nsc-dispatcher = forgejoNscDispatcher;
forgejo-nsc-autoscaler = forgejoNscAutoscaler;
}
// lib.optionalAttrs (nscPkg != null) { nsc = nscPkg; };
// lib.optionalAttrs (nscPkg != null) { nsc = nscPkg; }
// lib.optionalAttrs (googleKmsPkcs11Package != null) { google-kms-pkcs11 = googleKmsPkcs11Package; }
// lib.optionalAttrs (rcodesignPkcs11Package != null) { rcodesign-pkcs11 = rcodesignPkcs11Package; };
}))
// {
nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix;

View file

@ -0,0 +1,522 @@
--- a/apple-codesign/src/cli/certificate_source.rs
+++ b/apple-codesign/src/cli/certificate_source.rs
@@ -16,0 +17 @@
+ bytes::Bytes,
@@ -19,0 +21 @@
+ signature::Signer,
@@ -21,2 +23,10 @@
- std::path::PathBuf,
- x509_certificate::CapturedX509Certificate,
+ std::{
+ path::PathBuf,
+ process::Command,
+ sync::atomic::{AtomicU64, Ordering},
+ },
+ x509_certificate::{
+ CapturedX509Certificate, KeyAlgorithm, Sign, Signature, SignatureAlgorithm,
+ X509CertificateError,
+ },
+ zeroize::Zeroizing,
@@ -36,0 +47,2 @@
+static PKCS11_SIGN_COUNTER: AtomicU64 = AtomicU64::new(0);
+
@@ -204,0 +214,262 @@
+#[derive(Clone, Debug)]
+struct Pkcs11PrivateKey {
+ tool: String,
+ module: PathBuf,
+ slot_id: Option<String>,
+ token_label: Option<String>,
+ key_id: Option<String>,
+ key_label: Option<String>,
+ pin: String,
+ cert: CapturedX509Certificate,
+}
+
+impl signature::Signer<Signature> for Pkcs11PrivateKey {
+ fn try_sign(&self, message: &[u8]) -> Result<Signature, signature::Error> {
+ match self.cert.key_algorithm() {
+ Some(KeyAlgorithm::Rsa) => {}
+ Some(algorithm) => {
+ return Err(signature::Error::from_source(format!(
+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}"
+ )));
+ }
+ None => {
+ return Err(signature::Error::from_source(
+ "failed to resolve PKCS#11 certificate key algorithm",
+ ));
+ }
+ }
+
+ let sequence = PKCS11_SIGN_COUNTER.fetch_add(1, Ordering::Relaxed);
+ let base = std::env::temp_dir().join(format!(
+ "rcodesign-pkcs11-{}-{sequence}",
+ std::process::id()
+ ));
+ let input_path = base.with_extension("in");
+ let output_path = base.with_extension("sig");
+ let openssl_config_path = base.with_extension("openssl.cnf");
+ let provider_debug_path = base.with_extension("pkcs11-provider.log");
+
+ std::fs::write(&input_path, message).map_err(signature::Error::from_source)?;
+
+ let provider_override =
+ std::env::var("BURROW_APPLE_PKCS11_SIGN_WITH_OPENSSL_PROVIDER")
+ .or_else(|_| std::env::var("BURROW_PKCS11_SIGN_WITH_OPENSSL_PROVIDER"))
+ .ok()
+ .and_then(|value| {
+ if value.trim().is_empty() {
+ None
+ } else {
+ Some(matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"))
+ }
+ });
+ let use_openssl_provider = provider_override.unwrap_or_else(|| {
+ std::env::var("BURROW_APPLE_PKCS11_BACKEND")
+ .or_else(|_| std::env::var("BURROW_PKCS11_BACKEND"))
+ .map(|value| value == "gcp-kms")
+ .unwrap_or(false)
+ || self.module.to_string_lossy().contains("kmsp11")
+ || std::env::var("BURROW_GCP_KMS_PKCS11_CONFIG").is_ok()
+ || std::env::var("KMS_PKCS11_CONFIG").is_ok()
+ });
+
+ if use_openssl_provider {
+ let token_label = self.token_label.as_ref().ok_or_else(|| {
+ signature::Error::from_source(
+ "OpenSSL PKCS#11 provider signing requires --pkcs11-token-label",
+ )
+ })?;
+ let mut key_uri = format!("pkcs11:token={token_label}");
+ if let Some(label) = &self.key_label {
+ key_uri.push_str(";object=");
+ key_uri.push_str(label);
+ } else if let Some(id) = &self.key_id {
+ key_uri.push_str(";id=");
+ key_uri.push_str(id);
+ } else {
+ return Err(signature::Error::from_source(
+ "OpenSSL PKCS#11 provider signing requires --pkcs11-key-label or --pkcs11-key-id",
+ ));
+ }
+ key_uri.push_str(";type=private");
+
+ let pin_path = base.with_extension("pin");
+ std::fs::write(&pin_path, &self.pin).map_err(signature::Error::from_source)?;
+
+ let module_path = self.module.to_string_lossy();
+ let kms_config = std::env::var("KMS_PKCS11_CONFIG")
+ .or_else(|_| std::env::var("BURROW_GCP_KMS_PKCS11_CONFIG"))
+ .ok();
+ let needs_kms_config = module_path.contains("kmsp11") || kms_config.is_some();
+ if needs_kms_config {
+ let config_path = kms_config.as_ref().ok_or_else(|| {
+ signature::Error::from_source(
+ "OpenSSL PKCS#11 provider signing with Google KMS requires KMS_PKCS11_CONFIG",
+ )
+ })?;
+ let metadata = std::fs::metadata(config_path).map_err(|err| {
+ signature::Error::from_source(format!(
+ "OpenSSL PKCS#11 provider signing could not read KMS_PKCS11_CONFIG at {config_path}: {err}"
+ ))
+ })?;
+ if !metadata.is_file() || metadata.len() == 0 {
+ return Err(signature::Error::from_source(format!(
+ "OpenSSL PKCS#11 provider signing requires a non-empty KMS_PKCS11_CONFIG file at {config_path}"
+ )));
+ }
+ }
+
+ let openssl_modules = std::env::var("BURROW_OPENSSL_MODULES")
+ .or_else(|_| std::env::var("OPENSSL_MODULES"))
+ .ok()
+ .filter(|value| !value.trim().is_empty());
+ if let Some(modules) = &openssl_modules {
+ let provider_extension = if cfg!(target_os = "macos") {
+ "dylib"
+ } else {
+ "so"
+ };
+ let provider_module = PathBuf::from(modules)
+ .join(format!("pkcs11.{provider_extension}"));
+ if provider_module.is_file() {
+ let config = format!(
+ "HOME = .\nopenssl_conf = openssl_init\n\n[openssl_init]\nproviders = provider_sect\n\n[provider_sect]\ndefault = default_sect\npkcs11 = pkcs11_sect\n\n[default_sect]\nactivate = 1\n\n[pkcs11_sect]\nmodule = {}\npkcs11-module-path = {}\npkcs11-module-token-pin = file:{}\npkcs11-module-cache-pins = cache\npkcs11-module-quirks = no-operation-state no-allowed-mechanisms\nactivate = 1\n",
+ provider_module.display(),
+ self.module.display(),
+ pin_path.display(),
+ );
+ std::fs::write(&openssl_config_path, config)
+ .map_err(signature::Error::from_source)?;
+ }
+ }
+
+ let openssl = std::env::var("BURROW_OPENSSL").unwrap_or_else(|_| "openssl".into());
+ let mut command = Command::new(openssl);
+ command
+ .env("PKCS11_PROVIDER_MODULE", &self.module)
+ .arg("dgst")
+ .arg("-provider")
+ .arg("pkcs11")
+ .arg("-provider")
+ .arg("default")
+ .arg("-sha256")
+ .arg("-sign")
+ .arg(&key_uri)
+ .arg("-passin")
+ .arg(format!("file:{}", pin_path.display()))
+ .arg("-out")
+ .arg(&output_path)
+ .arg(&input_path);
+ if let Some(config_path) = kms_config {
+ command
+ .env("KMS_PKCS11_CONFIG", &config_path)
+ .env("BURROW_GCP_KMS_PKCS11_CONFIG", &config_path);
+ }
+ if let Ok(credentials_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
+ command.env("GOOGLE_APPLICATION_CREDENTIALS", credentials_path);
+ }
+ if let Some(modules) = openssl_modules {
+ command.env("OPENSSL_MODULES", modules);
+ }
+ if openssl_config_path.is_file() {
+ command.env("OPENSSL_CONF", &openssl_config_path);
+ }
+ if let Ok(debug) = std::env::var("BURROW_PKCS11_PROVIDER_DEBUG")
+ .or_else(|_| std::env::var("PKCS11_PROVIDER_DEBUG"))
+ {
+ command.env("PKCS11_PROVIDER_DEBUG", debug);
+ } else {
+ command.env(
+ "PKCS11_PROVIDER_DEBUG",
+ format!("file:{},level:1", provider_debug_path.display()),
+ );
+ }
+
+ let output = command.output().map_err(signature::Error::from_source)?;
+ let _ = std::fs::remove_file(&input_path);
+ let _ = std::fs::remove_file(&pin_path);
+ let _ = std::fs::remove_file(&openssl_config_path);
+
+ if !output.status.success() {
+ let _ = std::fs::remove_file(&output_path);
+ let provider_debug = std::fs::read_to_string(&provider_debug_path)
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| {
+ let tail = value
+ .lines()
+ .rev()
+ .take(80)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ .collect::<Vec<_>>()
+ .join("\n");
+ format!("\nPKCS#11 provider debug tail:\n{tail}\n")
+ })
+ .unwrap_or_default();
+ let _ = std::fs::remove_file(&provider_debug_path);
+ return Err(signature::Error::from_source(format!(
+ "OpenSSL PKCS#11 provider signing failed with status {}: {}{}{}",
+ output.status,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr),
+ provider_debug
+ )));
+ }
+ let _ = std::fs::remove_file(&provider_debug_path);
+ } else {
+ let mut command = Command::new(&self.tool);
+ command
+ .arg("--module")
+ .arg(&self.module)
+ .arg("--login")
+ .arg("--pin")
+ .arg(&self.pin)
+ .arg("--sign")
+ .arg("--mechanism")
+ .arg("SHA256-RSA-PKCS")
+ .arg("--input-file")
+ .arg(&input_path)
+ .arg("--output-file")
+ .arg(&output_path);
+
+ if let Some(slot_id) = &self.slot_id {
+ command.arg("--slot").arg(slot_id);
+ }
+ if let Some(token_label) = &self.token_label {
+ command.arg("--token-label").arg(token_label);
+ }
+ if let Some(id) = &self.key_id {
+ command.arg("--id").arg(id);
+ }
+ if let Some(label) = &self.key_label {
+ command.arg("--label").arg(label);
+ }
+
+ let output = command.output().map_err(signature::Error::from_source)?;
+
+ let _ = std::fs::remove_file(&input_path);
+
+ if !output.status.success() {
+ let _ = std::fs::remove_file(&output_path);
+ return Err(signature::Error::from_source(format!(
+ "pkcs11-tool signing failed with status {}: {}{}",
+ output.status,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )));
+ }
+ }
+
+ let signature = std::fs::read(&output_path).map_err(signature::Error::from_source)?;
+ let _ = std::fs::remove_file(&output_path);
+
+ if signature.is_empty() {
+ return Err(signature::Error::from_source(
+ "pkcs11-tool produced an empty signature",
+ ));
+ }
+
+ Ok(signature.into())
+ }
+}
@@ -204,0 +384,214 @@
+impl Sign for Pkcs11PrivateKey {
+ fn sign(&self, message: &[u8]) -> Result<(Vec<u8>, SignatureAlgorithm), X509CertificateError> {
+ let algorithm = self.signature_algorithm()?;
+
+ Ok((self.try_sign(message)?.into(), algorithm))
+ }
+
+ fn key_algorithm(&self) -> Option<KeyAlgorithm> {
+ self.cert.key_algorithm()
+ }
+
+ fn public_key_data(&self) -> Bytes {
+ self.cert.public_key_data()
+ }
+
+ fn signature_algorithm(&self) -> Result<SignatureAlgorithm, X509CertificateError> {
+ match self.cert.key_algorithm() {
+ Some(KeyAlgorithm::Rsa) => Ok(SignatureAlgorithm::RsaSha256),
+ Some(algorithm) => Err(X509CertificateError::UnknownSignatureAlgorithm(format!(
+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}"
+ ))),
+ None => Err(X509CertificateError::UnknownSignatureAlgorithm(
+ "failed to resolve PKCS#11 certificate key algorithm".into(),
+ )),
+ }
+ }
+
+ fn private_key_data(&self) -> Option<Zeroizing<Vec<u8>>> {
+ None
+ }
+
+ fn rsa_primes(
+ &self,
+ ) -> Result<Option<(Zeroizing<Vec<u8>>, Zeroizing<Vec<u8>>)>, X509CertificateError> {
+ Ok(None)
+ }
+}
+
+impl x509_certificate::KeyInfoSigner for Pkcs11PrivateKey {}
+
+impl PrivateKey for Pkcs11PrivateKey {
+ fn as_key_info_signer(&self) -> &dyn x509_certificate::KeyInfoSigner {
+ self
+ }
+
+ fn to_public_key_peer_decrypt(
+ &self,
+ ) -> Result<
+ Box<dyn crate::remote_signing::session_negotiation::PublicKeyPeerDecrypt>,
+ AppleCodesignError,
+ > {
+ Err(AppleCodesignError::CliGeneralError(
+ "PKCS#11 keys cannot decrypt remote-signing peer setup data".into(),
+ ))
+ }
+
+ fn finish(&self) -> Result<(), AppleCodesignError> {
+ Ok(())
+ }
+}
+
+#[derive(Args, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Pkcs11SigningKey {
+ /// Path to pkcs11-tool
+ #[arg(id = "pkcs11_tool", long = "pkcs11-tool", default_value = "pkcs11-tool", value_name = "PATH")]
+ pub tool: String,
+
+ /// Path to the PKCS#11 module to use for signing
+ #[arg(
+ long = "pkcs11-library",
+ alias = "pkcs11-module",
+ id = "pkcs11_library",
+ value_name = "PATH"
+ )]
+ pub module: Option<PathBuf>,
+
+ /// PKCS#11 slot id containing the signing key
+ #[arg(id = "pkcs11_slot_id", long = "pkcs11-slot-id", value_name = "SLOT")]
+ pub slot_id: Option<String>,
+
+ /// PKCS#11 token label containing the signing key
+ #[arg(id = "pkcs11_token_label", long = "pkcs11-token-label", value_name = "LABEL")]
+ pub token_label: Option<String>,
+
+ /// PKCS#11 private-key object id, usually hex
+ #[arg(id = "pkcs11_key_id", long = "pkcs11-key-id", value_name = "ID")]
+ pub key_id: Option<String>,
+
+ /// PKCS#11 private-key object label
+ #[arg(id = "pkcs11_key_label", long = "pkcs11-key-label", value_name = "LABEL")]
+ pub key_label: Option<String>,
+
+ /// PIN used to unlock the PKCS#11 token
+ #[arg(id = "pkcs11_pin", long = "pkcs11-pin", group = "pkcs11-pin-source", value_name = "PIN")]
+ pub pin: Option<String>,
+
+ /// File containing the PIN used to unlock the PKCS#11 token
+ #[arg(id = "pkcs11_pin_file", long = "pkcs11-pin-file", group = "pkcs11-pin-source", value_name = "PATH")]
+ pub pin_path: Option<PathBuf>,
+
+ /// Environment variable holding the PIN used to unlock the PKCS#11 token
+ #[arg(id = "pkcs11_pin_env", long = "pkcs11-pin-env", group = "pkcs11-pin-source", value_name = "ENV")]
+ #[serde(skip)]
+ pub pin_env: Option<String>,
+
+ /// DER certificate file corresponding to the PKCS#11 private key
+ #[arg(
+ long = "pkcs11-certificate-file",
+ alias = "pkcs11-certificate-der-file",
+ id = "pkcs11_certificate_file",
+ value_name = "PATH"
+ )]
+ pub certificate_path: Option<PathBuf>,
+}
+
+impl Pkcs11SigningKey {
+ pub(crate) fn configured(&self) -> bool {
+ self.module.is_some()
+ || self.slot_id.is_some()
+ || self.token_label.is_some()
+ || self.key_id.is_some()
+ || self.key_label.is_some()
+ || self.pin.is_some()
+ || self.pin_path.is_some()
+ || self.pin_env.is_some()
+ || self.certificate_path.is_some()
+ }
+
+ fn not_configured(&self) -> bool {
+ !self.configured()
+ }
+
+ fn resolve_pin(&self) -> Result<String, AppleCodesignError> {
+ if let Some(pin) = &self.pin {
+ Ok(pin.clone())
+ } else if let Some(path) = &self.pin_path {
+ Ok(std::fs::read_to_string(path)?.trim().to_string())
+ } else if let Some(env) = &self.pin_env {
+ std::env::var(env).map_err(|_| {
+ AppleCodesignError::CliGeneralError(format!(
+ "failed reading PKCS#11 PIN from {env} environment variable"
+ ))
+ })
+ } else {
+ Err(AppleCodesignError::CliGeneralError(
+ "--pkcs11-pin, --pkcs11-pin-file, or --pkcs11-pin-env is required".into(),
+ ))
+ }
+ }
+}
+
+impl KeySource for Pkcs11SigningKey {
+ fn resolve_certificates(&self) -> Result<SigningCertificates, AppleCodesignError> {
+ if !self.configured() {
+ return Ok(Default::default());
+ }
+
+ let module = self.module.clone().ok_or_else(|| {
+ AppleCodesignError::CliGeneralError("--pkcs11-library is required".into())
+ })?;
+ if self.slot_id.is_none() && self.token_label.is_none() {
+ return Err(AppleCodesignError::CliGeneralError(
+ "--pkcs11-slot-id or --pkcs11-token-label is required".into(),
+ ));
+ }
+ if self.key_id.is_none() && self.key_label.is_none() {
+ return Err(AppleCodesignError::CliGeneralError(
+ "--pkcs11-key-id or --pkcs11-key-label is required".into(),
+ ));
+ }
+ let certificate_path = self.certificate_path.clone().ok_or_else(|| {
+ AppleCodesignError::CliGeneralError("--pkcs11-certificate-file is required".into())
+ })?;
+ let pin = self.resolve_pin()?;
+
+ warn!(
+ "reading PKCS#11 certificate data from {}",
+ certificate_path.display()
+ );
+ let cert = CapturedX509Certificate::from_der(std::fs::read(certificate_path)?)?;
+
+ match cert.key_algorithm() {
+ Some(KeyAlgorithm::Rsa) => {}
+ Some(algorithm) => {
+ return Err(AppleCodesignError::CliGeneralError(format!(
+ "PKCS#11 signing currently supports RSA certificates, not {algorithm:?}"
+ )));
+ }
+ None => {
+ return Err(AppleCodesignError::CliGeneralError(
+ "failed to resolve PKCS#11 certificate key algorithm".into(),
+ ));
+ }
+ }
+
+ let signer = Pkcs11PrivateKey {
+ tool: self.tool.clone(),
+ module,
+ slot_id: self.slot_id.clone(),
+ token_label: self.token_label.clone(),
+ key_id: self.key_id.clone(),
+ key_label: self.key_label.clone(),
+ pin,
+ cert: cert.clone(),
+ };
+
+ Ok(SigningCertificates {
+ keys: vec![Box::new(signer)],
+ certs: vec![cert],
+ })
+ }
+}
+
@@ -643,0 +1037,8 @@
+ rename = "pkcs11",
+ skip_serializing_if = "Pkcs11SigningKey::not_configured"
+ )]
+ pub pkcs11_key: Pkcs11SigningKey,
+
+ #[command(flatten)]
+ #[serde(
+ default,
@@ -680,0 +1082,2 @@
+ res.push(&self.pkcs11_key as &dyn KeySource);
+
--- a/apple-codesign/src/cli/mod.rs
+++ b/apple-codesign/src/cli/mod.rs
@@ -1529 +1532,6 @@
- let certs = c.signer.resolve_certificates(true)?;
+ let signer = if self.certificate.pkcs11_key.configured() {
+ &self.certificate
+ } else {
+ &c.signer
+ };
+ let certs = signer.resolve_certificates(true)?;