From 9f5d30ebd023d0dab9d982f7f47a425727026a5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 14:25:38 -0700 Subject: [PATCH] fix(release): notarize macOS artifacts --- CHANGELOG.md | 4 + docs/RELEASING.md | 26 ++- scripts/codesign-release-binary.sh | 59 ++++++- scripts/release-signed.sh | 4 + scripts/release_signing_test.go | 249 ++++++++++++++++++++++++++++- scripts/verify-macos-release.sh | 2 + 6 files changed, 326 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8c7c1..8ae6ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.11.7 - Unreleased +### Maintenance + +- Notarize every signed macOS release binary and require Apple's online ticket verification before publication. + ## 0.11.6 - 2026-07-17 ### Highlights diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c6d85a9..e0a43ea 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -20,6 +20,7 @@ Assumptions: - GitHub CLI authenticated - CI green on `main` - OpenClaw Foundation Developer ID Application identity available through the managed release keychain +- A `NOTARYTOOL_KEYCHAIN_PROFILE` runtime value whose credentials are stored in the operator's login keychain ## 1) Verify build + tests @@ -56,7 +57,7 @@ git push origin main git push origin vX.Y.Z ``` -## 4) Publish signed release artifacts +## 4) Publish signed and notarized release artifacts From the clean checkout whose `HEAD` exactly matches the signed tag, publish all GoReleaser artifacts through the shared secret-safe keychain helper: @@ -66,16 +67,25 @@ GoReleaser artifacts through the shared secret-safe keychain helper: ``` The script uses an existing GitHub token environment variable or the -authenticated GitHub CLI without writing credentials to disk. - -The GoReleaser hook signs only the two macOS binaries. Snapshot builds and all -non-macOS targets remain credential-free. Production builds fail closed unless -the macOS artifacts use identifier `org.openclaw.discrawl` and OpenClaw -Foundation Team ID `FWJYW4S8P8`. +authenticated GitHub CLI without writing credentials to disk. Set +`NOTARYTOOL_KEYCHAIN_PROFILE` only in the private release environment; never +commit a real profile name or its credentials. + +The GoReleaser hook signs and notarizes both thin macOS binaries. Each binary is +signed into a temporary candidate with hardened runtime and a trusted timestamp, +submitted to Apple as an ephemeral ZIP with `notarytool --wait`, and accepted +only after the response reports `Accepted` and an online ticket check passes. +Failed signing or notarization leaves the GoReleaser output untouched. Snapshot +builds and all non-macOS targets remain credential-free. Production builds fail +closed unless the notary profile is set and the macOS artifacts use identifier +`org.openclaw.discrawl` and OpenClaw Foundation Team ID `FWJYW4S8P8`. Publishing the GitHub Release triggers `.github/workflows/release.yml`, which downloads both macOS archives on native runners, verifies their checksums and -Developer ID signatures, then dispatches the Homebrew update. +Developer ID signatures, hardened-runtime metadata, stable requirement, and +Apple notarization tickets, then dispatches the Homebrew update. Raw CLI +binaries cannot carry stapled tickets, so this verification requires network +access to Apple's ticket service. ## 5) Verify GitHub Release diff --git a/scripts/codesign-release-binary.sh b/scripts/codesign-release-binary.sh index 2905cc4..9a97ce9 100755 --- a/scripts/codesign-release-binary.sh +++ b/scripts/codesign-release-binary.sh @@ -9,7 +9,12 @@ EXPECTED_TEAM_ID=${DISCRAWL_CODESIGN_TEAM_ID:-FWJYW4S8P8} REQUIREMENT="identifier \"$IDENTIFIER\" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$EXPECTED_TEAM_ID\"" case "$TARGET" in - darwin_*) ;; + darwin_arm64*) EXPECTED_ARCH=arm64 ;; + darwin_amd64*) EXPECTED_ARCH=x86_64 ;; + darwin_*) + echo "unsupported macOS release target: $TARGET" >&2 + exit 1 + ;; *) exit 0 ;; esac @@ -26,16 +31,62 @@ fi echo "CODESIGN_IDENTITY is required; run the release through mac-release codesign-run" >&2 exit 1 } +[[ -n "${NOTARYTOOL_KEYCHAIN_PROFILE:-}" ]] || { + echo "NOTARYTOOL_KEYCHAIN_PROFILE is required for official macOS release notarization" >&2 + exit 1 +} [[ -f "$BINARY" ]] || { echo "release binary not found: $BINARY" >&2 exit 1 } +for tool in codesign ditto lipo mktemp mv plutil xcrun; do + command -v "$tool" >/dev/null 2>&1 || { + echo "missing required release command: $tool" >&2 + exit 1 + } +done + +binary_dir=$(cd "$(dirname "$BINARY")" && pwd) +binary_name=$(basename "$BINARY") +work_dir=$(mktemp -d "$binary_dir/.discrawl-notary.XXXXXX") +candidate="$work_dir/$binary_name" +submission="$work_dir/$binary_name.zip" +trap 'rm -rf "$work_dir"' EXIT +# Keep the GoReleaser output untouched unless signing, notarization, and every +# verification step succeeds. +cp -p "$BINARY" "$candidate" codesign --force --options runtime --timestamp \ - --identifier "$IDENTIFIER" --sign "$CODESIGN_IDENTITY" "$BINARY" -codesign --verify --strict -R="$REQUIREMENT" --verbose=2 "$BINARY" + --identifier "$IDENTIFIER" --sign "$CODESIGN_IDENTITY" "$candidate" + +ditto -c -k --sequesterRsrc --keepParent "$candidate" "$submission" +notary_result=$(xcrun notarytool submit "$submission" \ + --keychain-profile "$NOTARYTOOL_KEYCHAIN_PROFILE" \ + --no-s3-acceleration \ + --wait \ + --output-format json) +notary_status=$(plutil -extract status raw -o - - <<<"$notary_result") +notary_id=$(plutil -extract id raw -o - - <<<"$notary_result") +[[ "$notary_status" == Accepted ]] || { + echo "macOS notarization status is ${notary_status:-missing}, expected Accepted" >&2 + exit 1 +} +[[ "$notary_id" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]] || { + echo "macOS notarization response has an invalid submission id" >&2 + exit 1 +} + +codesign --verify --strict -R="$REQUIREMENT" --verbose=2 "$candidate" +codesign --verify --strict --check-notarization -R=notarized --verbose=2 "$candidate" -signature=$(codesign -dvvv "$BINARY" 2>&1) +signature=$(codesign -dvvv "$candidate" 2>&1) grep -Fx "Identifier=$IDENTIFIER" <<<"$signature" >/dev/null grep -Fx "TeamIdentifier=$EXPECTED_TEAM_ID" <<<"$signature" >/dev/null grep -F "Authority=Developer ID Application:" <<<"$signature" >/dev/null +grep -F '(runtime)' <<<"$signature" >/dev/null +[[ "$(lipo -archs "$candidate")" == "$EXPECTED_ARCH" ]] || { + echo "release binary must contain exactly the $EXPECTED_ARCH architecture" >&2 + exit 1 +} + +mv -f "$candidate" "$BINARY" diff --git a/scripts/release-signed.sh b/scripts/release-signed.sh index c2420d8..8eb9245 100755 --- a/scripts/release-signed.sh +++ b/scripts/release-signed.sh @@ -13,6 +13,10 @@ HELPER=${MAC_RELEASE_HELPER:-$HOME/Projects/agent-scripts/skills/release-mac-app echo "signed releases must run on macOS" >&2 exit 1 } +[[ -n "${NOTARYTOOL_KEYCHAIN_PROFILE:-}" ]] || { + echo "NOTARYTOOL_KEYCHAIN_PROFILE is required for official macOS release notarization" >&2 + exit 1 +} [[ -x "$HELPER" ]] || { echo "managed-keychain release helper not found: $HELPER" >&2 exit 1 diff --git a/scripts/release_signing_test.go b/scripts/release_signing_test.go index 8426d43..c070c4c 100644 --- a/scripts/release_signing_test.go +++ b/scripts/release_signing_test.go @@ -61,6 +61,183 @@ func TestCodesignReleaseBinaryFailsClosedForOfficialBuilds(t *testing.T) { } } +func TestCodesignReleaseBinaryFailsClosedWithoutNotaryProfile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("codesign-release-binary.sh is a POSIX shell script") + } + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + + tempDir := t.TempDir() + stubDir := filepath.Join(tempDir, "bin") + if err := os.Mkdir(stubDir, 0o755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(stubDir, "uname"), "#!/bin/sh\nprintf 'Darwin\\n'\n") + binary := filepath.Join(tempDir, "discrawl") + writeExecutable(t, binary, "#!/bin/sh\n") + + cmd := exec.CommandContext(t.Context(), bash, "./codesign-release-binary.sh", "darwin_arm64", binary) + cmd.Dir = signingScriptDir(t) + cmd.Env = signingTestEnv( + "DISCRAWL_CODESIGN_REQUIRED=1", + "CODESIGN_IDENTITY=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)", + "PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("official signing hook accepted a missing notary profile: %s", output) + } + if !strings.Contains(string(output), "NOTARYTOOL_KEYCHAIN_PROFILE is required") { + t.Fatalf("unexpected missing-profile failure: %s", output) + } +} + +func TestCodesignReleaseBinaryNotarizesBeforeReplacingArtifact(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("codesign-release-binary.sh is a POSIX shell script") + } + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + + for _, tc := range []struct { + name string + target string + arch string + }{ + {name: "arm64", target: "darwin_arm64_v8.0", arch: "arm64"}, + {name: "amd64", target: "darwin_amd64_v1", arch: "x86_64"}, + } { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + stubDir, logPath := writeSigningStubs(t, tempDir, tc.arch) + binary := filepath.Join(tempDir, "discrawl") + writeExecutable(t, binary, "#!/bin/sh\nprintf 'scratch\\n'\n") + + cmd := exec.CommandContext(t.Context(), bash, "./codesign-release-binary.sh", tc.target, binary) + cmd.Dir = signingScriptDir(t) + cmd.Env = signingTestEnv( + "DISCRAWL_CODESIGN_REQUIRED=1", + "CODESIGN_IDENTITY=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)", + "NOTARYTOOL_KEYCHAIN_PROFILE=test-profile", + "SIGNING_TEST_LOG="+logPath, + "PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("sign and notarize scratch artifact: %v\n%s", err, output) + } + + contents, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), "signed") { + t.Fatal("successful notarization did not replace the original artifact") + } + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + log := string(logBytes) + for _, want := range []string{ + "xcrun notarytool submit", + "--keychain-profile test-profile --no-s3-acceleration --wait --output-format json", + "codesign --verify --strict --check-notarization -R=notarized --verbose=2", + } { + if !strings.Contains(log, want) { + t.Fatalf("signing log missing %q:\n%s", want, log) + } + } + leftovers, err := filepath.Glob(filepath.Join(tempDir, ".discrawl-notary.*")) + if err != nil { + t.Fatal(err) + } + if len(leftovers) != 0 { + t.Fatalf("notarization scratch directories were not removed: %v", leftovers) + } + }) + } +} + +func TestCodesignReleaseBinaryPreservesArtifactOnNotaryRejection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("codesign-release-binary.sh is a POSIX shell script") + } + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + + tempDir := t.TempDir() + stubDir, logPath := writeSigningStubs(t, tempDir, "arm64") + binary := filepath.Join(tempDir, "discrawl") + original := []byte("#!/bin/sh\nprintf 'scratch\\n'\n") + if err := os.WriteFile(binary, original, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.CommandContext(t.Context(), bash, "./codesign-release-binary.sh", "darwin_arm64", binary) + cmd.Dir = signingScriptDir(t) + cmd.Env = signingTestEnv( + "DISCRAWL_CODESIGN_REQUIRED=1", + "CODESIGN_IDENTITY=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)", + "NOTARYTOOL_KEYCHAIN_PROFILE=test-profile", + "MOCK_NOTARY_STATUS=Invalid", + "SIGNING_TEST_LOG="+logPath, + "PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("signing hook accepted rejected notarization: %s", output) + } + if !strings.Contains(string(output), "notarization status is Invalid") { + t.Fatalf("unexpected rejection failure: %s", output) + } + contents, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + if string(contents) != string(original) { + t.Fatal("failed notarization mutated the original artifact") + } +} + +func TestReleaseSignedFailsClosedWithoutNotaryProfile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("release-signed.sh is a POSIX shell script") + } + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + + tempDir := t.TempDir() + stubDir := filepath.Join(tempDir, "bin") + if err := os.Mkdir(stubDir, 0o755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(stubDir, "uname"), "#!/bin/sh\nprintf 'Darwin\\n'\n") + + cmd := exec.CommandContext(t.Context(), bash, "./release-signed.sh", "v0.0.0") + cmd.Dir = signingScriptDir(t) + cmd.Env = signingTestEnv("PATH=" + stubDir + string(os.PathListSeparator) + os.Getenv("PATH")) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("release entrypoint accepted a missing notary profile: %s", output) + } + if !strings.Contains(string(output), "NOTARYTOOL_KEYCHAIN_PROFILE is required") { + t.Fatalf("unexpected release entrypoint failure: %s", output) + } +} + func TestVerifyMacOSReleaseAcceptsGoReleaserArchive(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("verify-macos-release.sh is a POSIX shell script") @@ -110,16 +287,28 @@ func TestVerifyMacOSReleaseAcceptsGoReleaserArchive(t *testing.T) { t.Fatal(err) } writeExecutable(t, filepath.Join(stubDir, "uname"), "#!/bin/sh\nprintf 'Darwin\\n'\n") - writeExecutable(t, filepath.Join(stubDir, "codesign"), "#!/bin/sh\nprintf 'Identifier=org.openclaw.discrawl\\nTeamIdentifier=FWJYW4S8P8\\nAuthority=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)\\n' >&2\n") + logPath := filepath.Join(tempDir, "codesign.log") + writeExecutable(t, filepath.Join(stubDir, "codesign"), "#!/bin/sh\nprintf 'codesign %s\\n' \"$*\" >> \"$SIGNING_TEST_LOG\"\nprintf 'Identifier=org.openclaw.discrawl\\nCodeDirectory v=20500 size=100 flags=0x10000(runtime) hashes=1+0 location=embedded\\nTeamIdentifier=FWJYW4S8P8\\nAuthority=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)\\n' >&2\n") writeExecutable(t, filepath.Join(stubDir, "lipo"), "#!/bin/sh\nprintf 'arm64\\n'\n") scriptDir := signingScriptDir(t) cmd := exec.CommandContext(t.Context(), bash, "./verify-macos-release.sh", "v0.11.5", archive, checksums) cmd.Dir = scriptDir - cmd.Env = append(os.Environ(), "PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + cmd.Env = append( + os.Environ(), + "SIGNING_TEST_LOG="+logPath, + "PATH="+stubDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("verify GoReleaser archive: %v\n%s", err, output) } + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logBytes), "--verify --strict --check-notarization -R=notarized --verbose=2") { + t.Fatalf("release verifier skipped notarization ticket check:\n%s", logBytes) + } } func signingScriptDir(t *testing.T) string { @@ -131,15 +320,63 @@ func signingScriptDir(t *testing.T) string { return filepath.Dir(testFile) } -func signingTestEnv(extra string) []string { - env := make([]string, 0, len(os.Environ())+1) +func signingTestEnv(extra ...string) []string { + env := make([]string, 0, len(os.Environ())+len(extra)) for _, entry := range os.Environ() { - if strings.HasPrefix(entry, "CODESIGN_IDENTITY=") || strings.HasPrefix(entry, "DISCRAWL_CODESIGN_REQUIRED=") { + if strings.HasPrefix(entry, "CODESIGN_IDENTITY=") || + strings.HasPrefix(entry, "DISCRAWL_CODESIGN_REQUIRED=") || + strings.HasPrefix(entry, "NOTARYTOOL_KEYCHAIN_PROFILE=") || + strings.HasPrefix(entry, "SIGNING_TEST_LOG=") || + strings.HasPrefix(entry, "MOCK_NOTARY_STATUS=") { continue } env = append(env, entry) } - return append(env, extra) + return append(env, extra...) +} + +func writeSigningStubs(t *testing.T, tempDir, arch string) (string, string) { + t.Helper() + stubDir := filepath.Join(tempDir, "bin") + if err := os.Mkdir(stubDir, 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(tempDir, "signing.log") + writeExecutable(t, filepath.Join(stubDir, "uname"), "#!/bin/sh\nprintf 'Darwin\\n'\n") + writeExecutable(t, filepath.Join(stubDir, "codesign"), `#!/usr/bin/env bash +set -euo pipefail +printf 'codesign %s\n' "$*" >> "$SIGNING_TEST_LOG" +if [[ " $* " == *' --sign '* ]]; then + target=${!#} + printf '\nsigned\n' >> "$target" +fi +if [[ "${1:-}" == -dvvv ]]; then + printf 'Identifier=org.openclaw.discrawl\nCodeDirectory v=20500 size=100 flags=0x10000(runtime) hashes=1+0 location=embedded\nTeamIdentifier=FWJYW4S8P8\nAuthority=Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)\n' >&2 +fi +`) + writeExecutable(t, filepath.Join(stubDir, "ditto"), `#!/usr/bin/env bash +set -euo pipefail +args=("$@") +source=${args[${#args[@]}-2]} +destination=${args[${#args[@]}-1]} +cp "$source" "$destination" +printf 'ditto %s\n' "$*" >> "$SIGNING_TEST_LOG" +`) + writeExecutable(t, filepath.Join(stubDir, "xcrun"), `#!/usr/bin/env bash +set -euo pipefail +printf 'xcrun %s\n' "$*" >> "$SIGNING_TEST_LOG" +printf '{"id":"12345678-1234-1234-1234-123456789abc","status":"%s"}\n' "${MOCK_NOTARY_STATUS:-Accepted}" +`) + writeExecutable(t, filepath.Join(stubDir, "plutil"), `#!/usr/bin/env bash +set -euo pipefail +case "${2:-}" in + status) printf '%s\n' "${MOCK_NOTARY_STATUS:-Accepted}" ;; + id) printf '%s\n' '12345678-1234-1234-1234-123456789abc' ;; + *) exit 1 ;; +esac +`) + writeExecutable(t, filepath.Join(stubDir, "lipo"), fmt.Sprintf("#!/bin/sh\nprintf '%s\\n'\n", arch)) + return stubDir, logPath } func writeExecutable(t *testing.T, path, contents string) { diff --git a/scripts/verify-macos-release.sh b/scripts/verify-macos-release.sh index 984c379..e139b44 100755 --- a/scripts/verify-macos-release.sh +++ b/scripts/verify-macos-release.sh @@ -56,9 +56,11 @@ binary="$WORK_DIR/discrawl" } codesign --verify --strict -R="$REQUIREMENT" --verbose=2 "$binary" +codesign --verify --strict --check-notarization -R=notarized --verbose=2 "$binary" signature=$(codesign -dvvv "$binary" 2>&1) grep -Fx "Identifier=$IDENTIFIER" <<<"$signature" >/dev/null grep -Fx "TeamIdentifier=$EXPECTED_TEAM_ID" <<<"$signature" >/dev/null grep -F "Authority=Developer ID Application:" <<<"$signature" >/dev/null +grep -F '(runtime)' <<<"$signature" >/dev/null lipo -archs "$binary" | tr ' ' '\n' | grep -Fx "$expected_arch" >/dev/null [[ "$("$binary" --version)" == "$release_version" ]]