diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da4c4a3..438fee85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.8.1 - Unreleased +- Require every official macOS release binary to pass hardened-runtime signing, Apple notarization, and independent Foundation and notarized-requirement verification before packaging. - Route embedding and semantic-search requests through a configurable embedding-only OpenAI-compatible endpoint while preserving the shared endpoint fallback. Thanks @larroy. ## 0.8.0 - 2026-07-17 diff --git a/docs/releasing.md b/docs/releasing.md index ccdb5e6b..f9f5ab75 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -8,8 +8,8 @@ permalink: /releasing/ Official releases are assembled locally on an authorized maintainer Mac. GitHub Actions only validates credential-free snapshots and never publishes release artifacts. 1. Prepare and sign the release commit and tag on `main`, then ensure the checkout is clean and `HEAD` exactly matches that tag. -2. Configure the shared `release-mac-app` helper at runtime for the passwordless managed keychain. The identity must be `Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)`. Keep keychain and 1Password routing in the ignored `.mac-release.env` or another approved private environment, never in Git. -3. Build all release archives locally; Darwin binaries are signed as `org.openclaw.gitcrawl`, while Linux and Windows builds remain ordinary cross-compiles: +2. Configure the shared `release-mac-app` helper at runtime for the passwordless managed keychain. The identity must be `Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)`. Supply `NOTARYTOOL_KEYCHAIN_PROFILE` through the approved private runtime environment; never commit its value or add it to GitHub Actions. Keep keychain and 1Password routing in the ignored `.mac-release.env` or another approved private environment, never in Git. +3. Build all release archives locally; each thin Darwin binary is signed as `org.openclaw.gitcrawl` with the hardened runtime and a trusted timestamp, submitted to Apple in an ephemeral ZIP, and required to pass the notarized code requirement before packaging. Linux and Windows builds remain ordinary cross-compiles: ```bash make release-artifacts VERSION=vX.Y.Z @@ -19,4 +19,6 @@ Official releases are assembled locally on an authorized maintainer Mac. GitHub 4. Create a draft GitHub release from the signed tag. Attach the archives and `checksums.txt` from `dist/`, then manually run the `Release Assets` workflow for that tag. Its ephemeral token has `contents: write` only because GitHub otherwise hides drafts; the token is scoped to read-only asset downloads and is removed before verification. Publish only after both macOS verification jobs pass. 5. After publication, verify the release notes and assets, then dispatch the `openclaw/homebrew-tap` formula update for `gitcrawl` and verify the installed binary. -Local `go build`, `make build`, tests, and GoReleaser snapshots never require release credentials. `scripts/package-release.sh` fails closed unless it runs from the exact trusted signed tag with the Foundation identity supplied by `release-mac-app codesign-run`. +Local `go build`, `make build`, tests, and credential-free GoReleaser snapshots never require release credentials. Official snapshots and releases set `GITCRAWL_REQUIRE_CODESIGN=1`; the signing hook then requires `NOTARYTOOL_KEYCHAIN_PROFILE`, waits for an accepted notarization response, and replaces the GoReleaser output only after online notarization verification succeeds. `scripts/verify-release.sh` independently checks every extracted Darwin binary for the Foundation designated requirement and the notarized requirement. Raw executables cannot carry a stapled ticket, so verification requires network access to Apple. + +`scripts/package-release.sh` fails closed unless it runs from the exact trusted signed tag with the Foundation identity supplied by `release-mac-app codesign-run` and a runtime notary profile. diff --git a/scripts/codesign-macos.sh b/scripts/codesign-macos.sh index e75088a7..0c4fbbc7 100755 --- a/scripts/codesign-macos.sh +++ b/scripts/codesign-macos.sh @@ -31,16 +31,57 @@ identity=${CODESIGN_IDENTITY:-} echo "codesign: official macOS releases require $expected_authority" >&2 exit 1 } +[[ -n "${NOTARYTOOL_KEYCHAIN_PROFILE:-}" ]] || { + echo "codesign: NOTARYTOOL_KEYCHAIN_PROFILE is required at runtime" >&2 + exit 1 +} + +for tool in codesign ditto mktemp mv plutil xcrun; do + command -v "$tool" >/dev/null 2>&1 || { + echo "codesign: missing required command: $tool" >&2 + exit 1 + } +done +binary_dir=$(cd "$(dirname "$binary")" && pwd) +binary_name=$(basename "$binary") +work_dir=$(mktemp -d "$binary_dir/.gitcrawl-notary.XXXXXX") +candidate="$work_dir/$binary_name" +submission="$work_dir/$binary_name.zip" +trap 'rm -rf "$work_dir"' EXIT + +# Keep the GoReleaser output unchanged unless the complete signing and +# notarization contract succeeds. +cp -p "$binary" "$candidate" codesign --force \ --options runtime \ --timestamp \ --identifier "$identifier" \ --sign "$identity" \ - "$binary" -codesign --verify --strict -R="$requirement" --verbose=2 "$binary" + "$candidate" +codesign --verify --strict -R="$requirement" --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 -Fx "Authority=$expected_authority" <<<"$signature" >/dev/null + +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 "codesign: notarization status is ${notary_status:-missing}, expected Accepted" >&2 + exit 1 +} +[[ "$notary_id" =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]] || { + echo "codesign: notarization response has an invalid submission id" >&2 + exit 1 +} + +codesign --verify --strict --check-notarization -R=notarized "$candidate" +mv -f "$candidate" "$binary" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 25089154..fe00c50a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -21,8 +21,12 @@ fi echo "official releases require $EXPECTED_AUTHORITY" >&2 exit 1 } +[[ -n "${NOTARYTOOL_KEYCHAIN_PROFILE:-}" ]] || { + echo "official releases require NOTARYTOOL_KEYCHAIN_PROFILE at runtime" >&2 + exit 1 +} -for tool in codesign git go goreleaser lipo shasum tar; do +for tool in codesign ditto git go goreleaser lipo plutil shasum tar xcrun; do command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2 exit 1 diff --git a/scripts/test-release.sh b/scripts/test-release.sh index 10f5e5e1..c81874ba 100755 --- a/scripts/test-release.sh +++ b/scripts/test-release.sh @@ -25,6 +25,9 @@ EOF cat > "$FAKE_BIN/codesign" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$*" >> "${MOCK_CODESIGN_LOG:?}" +if [[ " $* " == *' --check-notarization '* && "${MOCK_NOTARIZATION_REJECT:-0}" == 1 ]]; then + exit 1 +fi case " $* " in *' -dvvv '*) { @@ -36,6 +39,32 @@ case " $* " in esac EOF +cat > "$FAKE_BIN/ditto" <<'EOF' +#!/usr/bin/env bash +previous= +for arg in "$@"; do + source=$previous + previous=$arg +done +cp "$source" "$previous" +printf 'ditto %s\n' "$*" >> "${MOCK_CODESIGN_LOG:?}" +EOF + +cat > "$FAKE_BIN/xcrun" <<'EOF' +#!/usr/bin/env bash +printf 'xcrun %s\n' "$*" >> "${MOCK_CODESIGN_LOG:?}" +printf '{"id":"12345678-1234-1234-1234-123456789abc","status":"%s"}\n' "${MOCK_NOTARY_STATUS:-Accepted}" +EOF + +cat > "$FAKE_BIN/plutil" <<'EOF' +#!/usr/bin/env bash +case "${2:-}" in + status) printf '%s\n' "${MOCK_NOTARY_STATUS:-Accepted}" ;; + id) printf '%s\n' '12345678-1234-1234-1234-123456789abc' ;; + *) exit 1 ;; +esac +EOF + cat > "$FAKE_BIN/lipo" <<'EOF' #!/usr/bin/env bash case "${2:-}" in @@ -47,6 +76,7 @@ EOF chmod 0755 "$FAKE_BIN"/* export PATH="$FAKE_BIN:$PATH" export MOCK_CODESIGN_LOG="$WORK_DIR/codesign.log" +unset NOTARYTOOL_KEYCHAIN_PROFILE test_binary="$WORK_DIR/gitcrawl" cat > "$test_binary" <<'EOF' @@ -62,15 +92,48 @@ if CODESIGN_IDENTITY='Developer ID Application: Peter Steinberger (Y5PE65HELJ)' echo "package script accepted personal signing identity" >&2 exit 1 fi +if CODESIGN_IDENTITY="$EXPECTED_AUTHORITY" \ + "$ROOT/scripts/package-release.sh" v0.7.1 >/dev/null 2>&1; then + echo "package script accepted a missing notary profile" >&2 + exit 1 +fi if GITCRAWL_REQUIRE_CODESIGN=1 \ CODESIGN_IDENTITY='Developer ID Application: Peter Steinberger (Y5PE65HELJ)' \ "$ROOT/scripts/codesign-macos.sh" "$test_binary" >/dev/null 2>&1; then echo "personal signing identity was accepted" >&2 exit 1 fi +if GITCRAWL_REQUIRE_CODESIGN=1 \ + CODESIGN_IDENTITY="$EXPECTED_AUTHORITY" \ + "$ROOT/scripts/codesign-macos.sh" "$test_binary" >/dev/null 2>&1; then + echo "official signing accepted a missing notary profile" >&2 + exit 1 +fi GITCRAWL_REQUIRE_CODESIGN=1 \ CODESIGN_IDENTITY="$EXPECTED_AUTHORITY" \ + NOTARYTOOL_KEYCHAIN_PROFILE=test-profile \ "$ROOT/scripts/codesign-macos.sh" "$test_binary" +grep -F -- '--identifier org.openclaw.gitcrawl' "$MOCK_CODESIGN_LOG" >/dev/null +grep -F 'FWJYW4S8P8' "$MOCK_CODESIGN_LOG" >/dev/null +grep -F -- 'notarytool submit' "$MOCK_CODESIGN_LOG" >/dev/null +grep -F -- '--keychain-profile test-profile --no-s3-acceleration --wait --output-format json' "$MOCK_CODESIGN_LOG" >/dev/null +signed_hash=$(shasum -a 256 "$test_binary" | awk '{ print $1 }') +if GITCRAWL_REQUIRE_CODESIGN=1 \ + CODESIGN_IDENTITY="$EXPECTED_AUTHORITY" \ + NOTARYTOOL_KEYCHAIN_PROFILE=test-profile \ + MOCK_NOTARY_STATUS=Invalid \ + "$ROOT/scripts/codesign-macos.sh" "$test_binary" >/dev/null 2>&1; then + echo "invalid notarization status was accepted" >&2 + exit 1 +fi +[[ "$(shasum -a 256 "$test_binary" | awk '{ print $1 }')" == "$signed_hash" ]] || { + echo "failed notarization mutated the release binary" >&2 + exit 1 +} +if find "$WORK_DIR" -maxdepth 1 -name '.gitcrawl-notary.*' | grep -q .; then + echo "ephemeral notarization files were not removed" >&2 + exit 1 +fi ARTIFACTS="$WORK_DIR/artifacts" mkdir -p "$ARTIFACTS" @@ -84,7 +147,14 @@ for arch in amd64 arm64; do shasum -a 256 "$ARTIFACTS/$archive" | awk -v name="$archive" '{ print $1 " " name }' >> "$ARTIFACTS/checksums.txt" done +: > "$MOCK_CODESIGN_LOG" "$ROOT/scripts/verify-release.sh" v0.7.1 "$ARTIFACTS" +[[ "$(grep -F -c -- '--verify --strict --check-notarization -R=notarized' "$MOCK_CODESIGN_LOG")" == 2 ]] +if MOCK_NOTARIZATION_REJECT=1 \ + "$ROOT/scripts/verify-release.sh" v0.7.1 "$ARTIFACTS" >/dev/null 2>&1; then + echo "release verifier accepted a missing notarization ticket" >&2 + exit 1 +fi if MOCK_CODESIGN_AUTHORITY='Developer ID Application: Peter Steinberger (Y5PE65HELJ)' \ "$ROOT/scripts/verify-release.sh" v0.7.1 "$ARTIFACTS" >/dev/null 2>&1; then echo "personal signature was accepted" >&2 @@ -104,9 +174,6 @@ if "$ROOT/scripts/verify-release.sh" v0.7.1 "$ARTIFACTS" >/dev/null 2>&1; then exit 1 fi -grep -F -- '--identifier org.openclaw.gitcrawl' "$MOCK_CODESIGN_LOG" >/dev/null -grep -F 'FWJYW4S8P8' "$MOCK_CODESIGN_LOG" >/dev/null - release_workflow="$ROOT/.github/workflows/release-assets.yml" grep -F 'contents: write' "$release_workflow" >/dev/null grep -F "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)" "$release_workflow" >/dev/null @@ -121,6 +188,10 @@ grep -F 'persist-credentials: false' "$release_workflow" >/dev/null grep -F 'tag_name == $tag and (.draft == ($draft == "true"))' "$release_workflow" >/dev/null grep -F 'Accept: application/octet-stream' "$release_workflow" >/dev/null grep -F 'unset GH_TOKEN GITHUB_TOKEN' "$release_workflow" >/dev/null +if grep -R -F 'NOTARYTOOL_KEYCHAIN_PROFILE' "$ROOT/.github/workflows" >/dev/null; then + echo "notary profile must not be configured in GitHub Actions" >&2 + exit 1 +fi if grep -F 'gh release download' "$release_workflow" >/dev/null; then echo "release workflow cannot resolve draft assets through gh release download" >&2 exit 1 diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index ae30a09f..dbe87894 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -73,6 +73,7 @@ for goarch in "${architectures[@]}"; do } codesign --verify --strict -R="$REQUIREMENT" --verbose=2 "$binary" + codesign --verify --strict --check-notarization -R=notarized "$binary" signature=$(codesign -dvvv "$binary" 2>&1) grep -Fx "Identifier=$IDENTIFIER" <<<"$signature" >/dev/null grep -Fx "TeamIdentifier=$EXPECTED_TEAM_ID" <<<"$signature" >/dev/null