From 68ac9a839ae612a2a51d3fa6298088c929dbf159 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Fri, 17 Jul 2026 15:09:53 +0300 Subject: [PATCH 1/3] feat(release): sign macOS/Windows release binaries Mirror openframe-oss-tenant signing: Developer ID codesign + notarytool for darwin, Azure Trusted Signing (jsign 7.5) for windows. Signing runs in GoReleaser build hooks before archiving so archives, checksums.txt and the cosign bundle cover signed binaries; release job moves to macos-latest. No-op outside the release workflow (OPENFRAME_SIGN gate). --- .github/steps/setup-macos-signing/action.yml | 48 ++++++++++ .github/workflows/release.yml | 51 ++++++++++- .goreleaser.yml | 7 ++ docs/development/README.md | 1 + docs/development/release-signing.md | 74 ++++++++++++++++ scripts/sign-binary.sh | 93 ++++++++++++++++++++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 .github/steps/setup-macos-signing/action.yml create mode 100644 docs/development/release-signing.md create mode 100755 scripts/sign-binary.sh diff --git a/.github/steps/setup-macos-signing/action.yml b/.github/steps/setup-macos-signing/action.yml new file mode 100644 index 00000000..77f0cba1 --- /dev/null +++ b/.github/steps/setup-macos-signing/action.yml @@ -0,0 +1,48 @@ +name: Setup macOS code signing +description: >- + Import the Apple Developer ID certificate into a throwaway keychain and + export KEYCHAIN_PATH / SIGNING_IDENTITY for later steps. Mirrors the setup + portion of .github/steps/sign-macos-package in openframe-oss-tenant; the + actual signing happens in scripts/sign-binary.sh, driven by GoReleaser + build hooks. Delete the keychain afterwards with an `if: always()` step. +inputs: + apple_certificate_p12: + description: 'Base64 encoded Apple Developer certificate (.p12)' + required: true + apple_certificate_password: + description: 'Password for the .p12 certificate' + required: true + +runs: + using: "composite" + steps: + - name: Import certificate into throwaway keychain + shell: bash + env: + APPLE_CERTIFICATE_P12: ${{ inputs.apple_certificate_p12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple_certificate_password }} + run: | + CERTIFICATE_PATH="$RUNNER_TEMP/certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" + + # Decode certificate and create keychain + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERTIFICATE_PATH" + security create-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security unlock-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate and let codesign use the key non-interactively + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH" + + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + rm "$CERTIFICATE_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + if [[ -z "$SIGNING_IDENTITY" ]]; then + echo "::error::No Developer ID Application identity found in keychain" + exit 1 + fi + echo "Found signing identity: $SIGNING_IDENTITY" + echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7941f0de..1ac8c8e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,10 @@ jobs: release: name: "Release" needs: [version] - runs-on: ubuntu-latest + # macOS runner: binaries are code-signed in GoReleaser build hooks before + # archiving (checksums must cover the signed bits), and codesign/notarytool + # only exist on macOS. Windows Authenticode uses jsign, which runs anywhere. + runs-on: macos-latest if: github.event_name == 'workflow_dispatch' steps: # The self-updater pins the cosign signing identity to @@ -94,6 +97,25 @@ jobs: - name: Install syft (SBOM generator) uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + # --- Code signing setup (used by scripts/sign-binary.sh via GoReleaser + # build hooks). Mirrors openframe-oss-tenant's sign-macos-package / + # sign-windows-package steps and uses the same org secrets. + - name: Setup macOS code signing + uses: ./.github/steps/setup-macos-signing + with: + apple_certificate_p12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + apple_certificate_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Install jsign (Azure Trusted Signing client) + env: + JSIGN_VERSION: "7.5" + JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c" + run: | + curl -fsSL -o "$RUNNER_TEMP/jsign.jar" \ + "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" + echo "${JSIGN_SHA256} $RUNNER_TEMP/jsign.jar" | shasum -a 256 -c - + echo "JSIGN_JAR=$RUNNER_TEMP/jsign.jar" >> "$GITHUB_ENV" + - name: Create and push tag id: tag env: @@ -110,6 +132,19 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }} + # Enables scripts/sign-binary.sh in the build hooks. KEYCHAIN_PATH, + # SIGNING_IDENTITY and JSIGN_JAR arrive via GITHUB_ENV from the + # signing setup steps above. + OPENFRAME_SIGN: "1" + APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }} + AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} run: | goreleaser release --clean @@ -123,8 +158,11 @@ jobs: echo "===================================================================" # Guards the ldflags injection (-X cmd.version): a broken injection # ships binaries reporting "dev", which disables self-update. - BIN="$(find dist -type f -name openframe -path '*linux*amd64*' | head -1)" - if [ -z "$BIN" ]; then echo "::error::no linux/amd64 binary found in dist/"; find dist -type f | head -20; exit 1; fi + # Runner is macOS/arm64, so exercise the darwin/arm64 binary — this + # also proves the signed, notarized binary still executes. + BIN="$(find dist -type f -name openframe -path '*darwin*arm64*' | head -1)" + if [ -z "$BIN" ]; then echo "::error::no darwin/arm64 binary found in dist/"; find dist -type f | head -20; exit 1; fi + codesign --verify --strict --verbose=2 "$BIN" got="$("$BIN" --version)" echo "$got" if ! echo "$got" | grep -qF "$VERSION"; then @@ -169,3 +207,10 @@ jobs: echo "===================================================================" gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete" git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":refs/tags/${VERSION}" && echo "deleted tag ${VERSION}" + + - name: 'Cleanup: delete signing keychain' + if: always() + run: | + if [[ -n "${KEYCHAIN_PATH:-}" && -f "${KEYCHAIN_PATH}" ]]; then + security delete-keychain "$KEYCHAIN_PATH" + fi diff --git a/.goreleaser.yml b/.goreleaser.yml index 558f20b4..7af6cf36 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -29,6 +29,13 @@ builds: - -X github.com/flamingo-stack/openframe-cli/cmd.version={{.Version}} - -X github.com/flamingo-stack/openframe-cli/cmd.commit={{.Commit}} - -X github.com/flamingo-stack/openframe-cli/cmd.date={{.Date}} + # Release-only code signing (macOS: codesign + notarytool; Windows: Azure + # Trusted Signing). Must run before archiving so archives/checksums cover + # the signed binaries. No-op unless OPENFRAME_SIGN=1 — see the script. + hooks: + post: + - cmd: ./scripts/sign-binary.sh {{ .Os }} {{ .Arch }} "{{ .Path }}" + output: true archives: - id: openframe-cli-archive diff --git a/docs/development/README.md b/docs/development/README.md index 2db5a86c..6b4d5a9b 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -7,6 +7,7 @@ Guides for building, running, and contributing to OpenFrame CLI. - **[Architecture Overview](architecture/README.md)** - High-level design, components, and data flows - **[Environment Setup](setup/environment.md)** - IDE, tools, and development dependencies - **[Local Development](setup/local-development.md)** - Clone, build, run, test, and debug locally +- **[Release Signing](release-signing.md)** - How release binaries are signed and notarized (macOS/Windows) ## Key Technologies diff --git a/docs/development/release-signing.md b/docs/development/release-signing.md new file mode 100644 index 00000000..481e9db6 --- /dev/null +++ b/docs/development/release-signing.md @@ -0,0 +1,74 @@ +# Release Signing + +Release binaries are code-signed during `goreleaser release` (see +[release.yml](../../.github/workflows/release.yml)), **before archiving** — so +the published archives, `checksums.txt`, and the cosign bundle all cover the +signed binaries. + +| Platform | Mechanism | +|----------|-----------| +| macOS | `codesign` (Developer ID Application, hardened runtime, timestamp) + `notarytool` notarization | +| Windows | Authenticode via Azure Trusted Signing (SHA-256, RFC3161 timestamp from `timestamp.acs.microsoft.com`) | +| Linux | Unsigned; integrity via `checksums.txt` + cosign bundle | + +The flow mirrors the `sign-macos-package` / `sign-windows-package` composite +steps in [openframe-oss-tenant](https://github.com/flamingo-stack/openframe-oss-tenant) +and uses the same certificates and secrets. One deviation: the release job runs +on a single macOS runner (signing must happen before GoReleaser packs the +archives), so Windows signing uses [jsign](https://ebourg.github.io/jsign/) +instead of `azure/trusted-signing-action` (signtool is Windows-only) — same +Azure Trusted Signing account, endpoint and certificate profile. + +## How it's wired + +- A GoReleaser build post-hook calls `scripts/sign-binary.sh ` + for every built binary. `linux` is a pass-through. +- The script is a no-op unless `OPENFRAME_SIGN=1`, which only the release + workflow sets — local builds and CI compile checks never attempt to sign. +- [.github/steps/setup-macos-signing](../../.github/steps/setup-macos-signing/action.yml) + imports the Developer ID certificate into a throwaway keychain and exports + `KEYCHAIN_PATH` / `SIGNING_IDENTITY`; the keychain is deleted in an + `if: always()` cleanup step. +- The workflow downloads a version-pinned, checksum-verified jsign jar and + exports `JSIGN_JAR`. The script fetches a fresh AAD client-credentials token + per Windows binary (notarization waits can outlive a token fetched up-front). + +## Required secrets + +Same names as `openframe-oss-tenant`, so org-level secrets cover both repos. + +| Secret | Used for | +|--------|----------| +| `APPLE_CERTIFICATE_P12` | Base64-encoded Developer ID Application certificate (.p12) | +| `APPLE_CERTIFICATE_PASSWORD` | Password for the .p12 | +| `APPLE_ID_USERNAME` / `APPLE_ID_PASSWORD` | Notarization (app-specific password) | +| `APPLE_TEAM_ID` | Apple Developer Team ID | +| `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` | AAD token for Trusted Signing | +| `AZURE_SIGNING_ENDPOINT` | e.g. `https://eus.codesigning.azure.net` | +| `AZURE_CODE_SIGNING_ACCOUNT_NAME` | Trusted Signing account | +| `AZURE_CERTIFICATE_PROFILE_NAME` | Certificate profile | + +## Verifying a released binary + +macOS: + +```bash +codesign --verify --strict --verbose=2 openframe +spctl --assess --type open --context context:primary-signature -v openframe +``` + +Windows (PowerShell): + +```powershell +Get-AuthenticodeSignature .\openframe.exe +``` + +Any platform — release provenance (covers Linux too), see the `signs` block in +[.goreleaser.yml](../../.goreleaser.yml): + +```bash +cosign verify-blob --bundle checksums.txt.bundle \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/flamingo-stack/openframe-cli/\.github/workflows/release\.yml@.*$' \ + checksums.txt +``` diff --git a/scripts/sign-binary.sh b/scripts/sign-binary.sh new file mode 100755 index 00000000..3a25d5f2 --- /dev/null +++ b/scripts/sign-binary.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Signs one GoReleaser-built binary before it is archived, so the published +# archives, checksums.txt and the cosign bundle all cover the signed bits. +# Invoked by the build post-hook in .goreleaser.yml: +# sign-binary.sh +# +# No-op unless OPENFRAME_SIGN=1 (set only by the release workflow) — local +# builds and CI compile checks never attempt to sign. +# +# darwin → codesign (Developer ID, hardened runtime) + notarytool, mirroring +# .github/steps/sign-macos-package in openframe-oss-tenant. +# windows → Azure Trusted Signing (same AZURE_* secrets as openframe-oss-tenant) +# via jsign, which unlike signtool runs on the macOS release runner. +# linux → unsigned; covered by checksums.txt + cosign bundle only. +set -euo pipefail + +OS="${1:?usage: sign-binary.sh }" +ARCH="${2:?usage: sign-binary.sh }" +BINARY="${3:?usage: sign-binary.sh }" + +if [[ "${OPENFRAME_SIGN:-0}" != "1" ]]; then + echo "sign-binary: OPENFRAME_SIGN != 1, skipping ${OS}/${ARCH}" + exit 0 +fi + +sign_darwin() { + : "${SIGNING_IDENTITY:?setup-macos-signing must run first}" + : "${KEYCHAIN_PATH:?setup-macos-signing must run first}" + : "${APPLE_ID_USERNAME:?}" + : "${APPLE_ID_PASSWORD:?}" + : "${APPLE_TEAM_ID:?}" + + codesign --sign "$SIGNING_IDENTITY" --keychain "$KEYCHAIN_PATH" \ + --timestamp --options runtime --force "$BINARY" + codesign --verify --strict --verbose=2 "$BINARY" + + # Bare binaries can't be stapled; notarization is still recorded online and + # checked by Gatekeeper on first run (same flow as openframe-oss-tenant). + local zip + zip="$(mktemp -d)/openframe-${OS}-${ARCH}.zip" + zip -j "$zip" "$BINARY" + xcrun notarytool submit "$zip" \ + --apple-id "$APPLE_ID_USERNAME" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --timeout 30m + rm -f "$zip" + + echo "sign-binary: ${OS}/${ARCH} signed and notarized" +} + +sign_windows() { + : "${JSIGN_JAR:?install-jsign must run first}" + : "${AZURE_TENANT_ID:?}" + : "${AZURE_CLIENT_ID:?}" + : "${AZURE_CLIENT_SECRET:?}" + : "${AZURE_SIGNING_ENDPOINT:?}" + : "${AZURE_CODE_SIGNING_ACCOUNT_NAME:?}" + : "${AZURE_CERTIFICATE_PROFILE_NAME:?}" + + # Fetch the AAD token per binary: darwin notarization waits earlier in the + # build can outlive a token fetched up-front. + local token + token="$(curl -fsS -X POST "https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=${AZURE_CLIENT_ID}" \ + --data-urlencode "client_secret=${AZURE_CLIENT_SECRET}" \ + --data-urlencode "scope=https://codesigning.azure.net/.default" \ + | jq -r '.access_token // empty')" + if [[ -z "$token" ]]; then + echo "sign-binary: failed to obtain Azure Trusted Signing token" >&2 + exit 1 + fi + + java -jar "$JSIGN_JAR" \ + --storetype TRUSTEDSIGNING \ + --keystore "${AZURE_SIGNING_ENDPOINT#https://}" \ + --storepass "$token" \ + --alias "${AZURE_CODE_SIGNING_ACCOUNT_NAME}/${AZURE_CERTIFICATE_PROFILE_NAME}" \ + --alg SHA-256 \ + --tsaurl http://timestamp.acs.microsoft.com \ + --tsmode RFC3161 \ + "$BINARY" + + echo "sign-binary: ${OS}/${ARCH} Authenticode-signed via Azure Trusted Signing" +} + +case "$OS" in + darwin) sign_darwin ;; + windows) sign_windows ;; + *) echo "sign-binary: ${OS}/${ARCH} not signed (by design)" ;; +esac From d1400bc7794a72c484c2d7f1d8f970bc24e9e3d4 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Fri, 17 Jul 2026 15:20:39 +0300 Subject: [PATCH 2/3] test(release): verify code signatures at unit and post-publish level Unit tests (tests/scripts, wired into UNIT_PKGS) run sign-binary.sh with PATH stubs recording argv: OPENFRAME_SIGN gate, per-OS dispatch, exact codesign/notarytool/jsign flags, call ordering, fail-fast on missing env. Post-publish release jobs download published assets on real Windows/macOS runners: Get-AuthenticodeSignature (status + timestamp) and codesign verify + Developer ID authority check with best-effort spctl notarization assessment. --- .github/workflows/release.yml | 74 +++++++++ .github/workflows/test.yml | 2 +- Makefile | 7 +- docs/development/release-signing.md | 16 ++ tests/scripts/sign_binary_test.go | 245 ++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 tests/scripts/sign_binary_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ac8c8e3..8bff3dfd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,3 +214,77 @@ jobs: if [[ -n "${KEYCHAIN_PATH:-}" && -f "${KEYCHAIN_PATH}" ]]; then security delete-keychain "$KEYCHAIN_PATH" fi + + # --------------------------------------------------------------------------- + # Post-publish signature verification: download the PUBLISHED assets on real + # Windows/macOS runners and verify what users actually get — the OS trust + # store checks here can't run on the release runner. No secrets needed. + # A failure does NOT auto-yank the release (the release job's cleanup only + # covers its own failure): a bad signature at this point means investigate + # and delete the release manually. + verify-windows-signature: + name: "Verify: Windows Authenticode" + needs: [version, release] + runs-on: windows-latest + steps: + - name: Download and verify published Windows binaries + shell: pwsh + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + $ErrorActionPreference = "Stop" + foreach ($arch in @("amd64", "arm64")) { + $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:VERSION/openframe-cli_windows_$arch.zip" + Write-Host "Verifying $url" + Invoke-WebRequest -Uri $url -OutFile "openframe_$arch.zip" + Expand-Archive "openframe_$arch.zip" -DestinationPath "extracted_$arch" + $sig = Get-AuthenticodeSignature -FilePath "extracted_$arch/openframe.exe" + + Write-Host " Status: $($sig.Status)" + if ($sig.SignerCertificate) { + Write-Host " Signer: $($sig.SignerCertificate.Subject)" + Write-Host " Issuer: $($sig.SignerCertificate.Issuer)" + } + if ($sig.Status -ne "Valid") { + Write-Error "Signature verification failed for ${arch}: $($sig.Status) — $($sig.StatusMessage)" + exit 1 + } + if (-not $sig.TimeStamperCertificate) { + Write-Error "Signature for ${arch} is not timestamped" + exit 1 + } + Write-Host " Timestamp Authority: $($sig.TimeStamperCertificate.Subject)" + } + Write-Host "All Windows signatures verified" + + verify-macos-signature: + name: "Verify: macOS signature & notarization" + needs: [version, release] + runs-on: macos-latest + steps: + - name: Download and verify published macOS binaries + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + for arch in amd64 arm64; do + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/openframe-cli_darwin_${arch}.tar.gz" + echo "Verifying $url" + curl -fsSL --retry 3 -o "openframe_${arch}.tar.gz" "$url" + mkdir -p "extracted_${arch}" + tar -xzf "openframe_${arch}.tar.gz" -C "extracted_${arch}" + bin="extracted_${arch}/openframe" + + codesign --verify --strict --verbose=2 "$bin" + info="$(codesign -dvv "$bin" 2>&1)" + echo "$info" + if ! echo "$info" | grep -q "Authority=Developer ID Application"; then + echo "::error::${arch} binary is not signed with a Developer ID Application certificate" + exit 1 + fi + # Notarization check is best-effort: spctl needs quarantine context + # and the ticket can lag right after publication (bare binaries + # can't be stapled), so log the assessment without failing. + spctl --assess --type open --context context:primary-signature --verbose "$bin" || true + done + echo "All macOS signatures verified" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 392be3e6..816f3c42 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -558,7 +558,7 @@ jobs: shell: bash run: | echo "===================================================================" - echo "=== TEST: unit tests (root, cmd, internal, tests/testutil)" + echo "=== TEST: unit tests (root, cmd, internal, tests/testutil, tests/scripts)" echo "===================================================================" make test-unit diff --git a/Makefile b/Makefile index d6e42b61..bc4d6831 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,10 @@ GOARCH := $(shell go env GOARCH) BINARY_SUFFIX := $(if $(filter windows,$(GOOS)),.exe,) # Unit-test package set. Includes the root package (main_test.go — the only -# exit-code fidelity tests) and tests/testutil, which `./cmd/... ./internal/...` -# silently skipped. Deliberately excludes ./tests/integration/... (real clusters). -UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/... +# exit-code fidelity tests), tests/testutil and tests/scripts (release script +# tests), which `./cmd/... ./internal/...` silently skipped. Deliberately +# excludes ./tests/integration/... (real clusters). +UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/... ./tests/scripts/... # Default target all: build diff --git a/docs/development/release-signing.md b/docs/development/release-signing.md index 481e9db6..9e1c0c8c 100644 --- a/docs/development/release-signing.md +++ b/docs/development/release-signing.md @@ -48,6 +48,22 @@ Same names as `openframe-oss-tenant`, so org-level secrets cover both repos. | `AZURE_CODE_SIGNING_ACCOUNT_NAME` | Trusted Signing account | | `AZURE_CERTIFICATE_PROFILE_NAME` | Certificate profile | +## Testing + +Two layers, neither needing certificates locally: + +- **Unit tests** — `tests/scripts/sign_binary_test.go` (part of + `make test-unit`) runs `scripts/sign-binary.sh` with PATH stubs for + `codesign`/`xcrun`/`java`/`curl`/`jq` that record their argv. They pin the + `OPENFRAME_SIGN` gate, the per-OS dispatch, fail-fast on missing env, and the + exact flags passed to codesign/notarytool/jsign (identity, hardened runtime, + endpoint scheme-stripping, alias, timestamp URL, call ordering). +- **Post-publish verification** — the `verify-windows-signature` / + `verify-macos-signature` jobs in the release workflow download the published + assets on real Windows/macOS runners and verify them against the OS trust + stores (`Get-AuthenticodeSignature` incl. timestamp; `codesign --verify` + + Developer ID authority check, best-effort `spctl` notarization assessment). + ## Verifying a released binary macOS: diff --git a/tests/scripts/sign_binary_test.go b/tests/scripts/sign_binary_test.go new file mode 100644 index 00000000..91ce0981 --- /dev/null +++ b/tests/scripts/sign_binary_test.go @@ -0,0 +1,245 @@ +// Package scripts_test covers the repo's release shell scripts; it contains +// no production code. +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for scripts/sign-binary.sh, the release-signing dispatcher invoked by +// GoReleaser build hooks. The real signing tools (codesign, xcrun, jsign via +// java, curl, jq, zip) are replaced with PATH stubs that record their argv to +// a call log, so these tests validate the OPENFRAME_SIGN gate, the per-OS +// dispatch, and the exact flags each tool receives — without certificates or +// network. The real signatures are verified post-publish by the +// verify-*-signature jobs in .github/workflows/release.yml. + +const stubBody = `#!/bin/sh +printf '%s %s\n' "$(basename "$0")" "$*" >> "$CALL_LOG" +exit 0 +` + +// curl prints CURL_BODY (the fake AAD token response); jq prints JQ_OUT (the +// fake extracted token) after draining stdin. +const curlStubBody = `#!/bin/sh +printf '%s %s\n' curl "$*" >> "$CALL_LOG" +printf '%s' "$CURL_BODY" +` + +const jqStubBody = `#!/bin/sh +printf '%s %s\n' jq "$*" >> "$CALL_LOG" +cat > /dev/null +printf '%s' "$JQ_OUT" +` + +type signScriptRun struct { + exitCode int + output string + calls []string +} + +// darwinEnv / windowsEnv are the full sets the release workflow provides. +func darwinEnv() map[string]string { + return map[string]string{ + "OPENFRAME_SIGN": "1", + "SIGNING_IDENTITY": "Developer ID Application: Test (TEAM123)", + "KEYCHAIN_PATH": "/fake/signing.keychain-db", + "APPLE_ID_USERNAME": "notary@example.com", + "APPLE_ID_PASSWORD": "app-specific-pass", + "APPLE_TEAM_ID": "TEAM123", + } +} + +func windowsEnv(jsignJar string) map[string]string { + return map[string]string{ + "OPENFRAME_SIGN": "1", + "JSIGN_JAR": jsignJar, + "AZURE_TENANT_ID": "tenant-guid", + "AZURE_CLIENT_ID": "client-guid", + "AZURE_CLIENT_SECRET": "client-secret", + "AZURE_SIGNING_ENDPOINT": "https://eus.codesigning.azure.net", + "AZURE_CODE_SIGNING_ACCOUNT_NAME": "flamingo-account", + "AZURE_CERTIFICATE_PROFILE_NAME": "flamingo-profile", + "CURL_BODY": `{"access_token":"fake-token"}`, + "JQ_OUT": "fake-token", + } +} + +func runSignScript(t *testing.T, env map[string]string, args ...string) signScriptRun { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("sign-binary.sh runs only on the macOS release runner") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + stubDir := t.TempDir() + for name, body := range map[string]string{ + "codesign": stubBody, + "xcrun": stubBody, + "zip": stubBody, + "java": stubBody, + "curl": curlStubBody, + "jq": jqStubBody, + } { + require.NoError(t, os.WriteFile(filepath.Join(stubDir, name), []byte(body), 0o755)) + } + callLog := filepath.Join(t.TempDir(), "calls.log") + + script, err := filepath.Abs(filepath.Join("..", "..", "scripts", "sign-binary.sh")) + require.NoError(t, err) + require.FileExists(t, script) + + cmd := exec.Command("bash", append([]string{script}, args...)...) + cmd.Env = []string{ + "PATH=" + stubDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "TMPDIR=" + t.TempDir(), + "CALL_LOG=" + callLog, + } + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + out, runErr := cmd.CombinedOutput() + run := signScriptRun{output: string(out)} + if runErr != nil { + var exitErr *exec.ExitError + require.ErrorAs(t, runErr, &exitErr, "script did not run: %v\n%s", runErr, out) + run.exitCode = exitErr.ExitCode() + } + if logBytes, err := os.ReadFile(callLog); err == nil { + run.calls = strings.Split(strings.TrimSpace(string(logBytes)), "\n") + if len(run.calls) == 1 && run.calls[0] == "" { + run.calls = nil + } + } + return run +} + +// findCall returns the index of the first recorded call containing every +// fragment, or -1. +func findCall(calls []string, fragments ...string) int { + for i, call := range calls { + matched := true + for _, f := range fragments { + if !strings.Contains(call, f) { + matched = false + break + } + } + if matched { + return i + } + } + return -1 +} + +func fakeBinary(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "openframe") + require.NoError(t, os.WriteFile(path, []byte("fake binary"), 0o755)) + return path +} + +func TestSignBinaryScript_GateSkipsWithoutOptIn(t *testing.T) { + run := runSignScript(t, nil, "darwin", "arm64", fakeBinary(t)) + assert.Equal(t, 0, run.exitCode, run.output) + assert.Contains(t, run.output, "skipping darwin/arm64") + assert.Empty(t, run.calls, "no signing tool must be invoked without the opt-in gate") +} + +func TestSignBinaryScript_LinuxIsNeverSigned(t *testing.T) { + run := runSignScript(t, map[string]string{"OPENFRAME_SIGN": "1"}, "linux", "amd64", fakeBinary(t)) + assert.Equal(t, 0, run.exitCode, run.output) + assert.Contains(t, run.output, "not signed (by design)") + assert.Empty(t, run.calls) +} + +func TestSignBinaryScript_RequiresArguments(t *testing.T) { + run := runSignScript(t, map[string]string{"OPENFRAME_SIGN": "1"}, "darwin", "arm64") + assert.NotEqual(t, 0, run.exitCode, "missing must fail") + assert.Contains(t, run.output, "usage:") +} + +func TestSignBinaryScript_DarwinSignsAndNotarizes(t *testing.T) { + bin := fakeBinary(t) + run := runSignScript(t, darwinEnv(), "darwin", "arm64", bin) + require.Equal(t, 0, run.exitCode, run.output) + + sign := findCall(run.calls, + "codesign --sign Developer ID Application: Test (TEAM123)", + "--keychain /fake/signing.keychain-db", + "--timestamp", "--options runtime", "--force", bin) + require.NotEqual(t, -1, sign, "codesign --sign call missing or wrong flags:\n%s", strings.Join(run.calls, "\n")) + + verify := findCall(run.calls, "codesign --verify --strict", bin) + require.NotEqual(t, -1, verify, "codesign --verify call missing") + + notarize := findCall(run.calls, + "xcrun notarytool submit", + "--apple-id notary@example.com", + "--team-id TEAM123", + "--wait") + require.NotEqual(t, -1, notarize, "notarytool submit call missing or wrong flags") + + assert.Less(t, sign, verify, "must verify after signing") + assert.Less(t, verify, notarize, "must notarize only a verified signature") + assert.Contains(t, run.output, "signed and notarized") +} + +func TestSignBinaryScript_DarwinRequiresIdentity(t *testing.T) { + env := darwinEnv() + delete(env, "SIGNING_IDENTITY") + run := runSignScript(t, env, "darwin", "arm64", fakeBinary(t)) + assert.NotEqual(t, 0, run.exitCode) + assert.Equal(t, -1, findCall(run.calls, "codesign"), "must not sign without an identity") +} + +func TestSignBinaryScript_WindowsSignsViaTrustedSigning(t *testing.T) { + bin := fakeBinary(t) + run := runSignScript(t, windowsEnv("/fake/jsign.jar"), "windows", "amd64", bin) + require.Equal(t, 0, run.exitCode, run.output) + + token := findCall(run.calls, "curl", "https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token") + require.NotEqual(t, -1, token, "AAD token request missing:\n%s", strings.Join(run.calls, "\n")) + + sign := findCall(run.calls, + "java -jar /fake/jsign.jar", + "--storetype TRUSTEDSIGNING", + "--keystore eus.codesigning.azure.net", // https:// prefix must be stripped for jsign + "--storepass fake-token", + "--alias flamingo-account/flamingo-profile", + "--tsaurl http://timestamp.acs.microsoft.com", + "--tsmode RFC3161", + bin) + require.NotEqual(t, -1, sign, "jsign call missing or wrong flags:\n%s", strings.Join(run.calls, "\n")) + assert.Less(t, token, sign, "token must be fetched before signing") +} + +func TestSignBinaryScript_WindowsFailsWithoutToken(t *testing.T) { + env := windowsEnv("/fake/jsign.jar") + env["CURL_BODY"] = `{"error":"invalid_client"}` + env["JQ_OUT"] = "" + run := runSignScript(t, env, "windows", "amd64", fakeBinary(t)) + assert.NotEqual(t, 0, run.exitCode) + assert.Contains(t, run.output, "failed to obtain Azure Trusted Signing token") + assert.Equal(t, -1, findCall(run.calls, "java"), "must not invoke jsign without a token") +} + +func TestSignBinaryScript_WindowsRequiresAzureConfig(t *testing.T) { + env := windowsEnv("/fake/jsign.jar") + delete(env, "AZURE_CLIENT_SECRET") + run := runSignScript(t, env, "windows", "amd64", fakeBinary(t)) + assert.NotEqual(t, 0, run.exitCode) + assert.Empty(t, run.calls, "must fail fast before calling any tool") +} From 775625dfd8455edebfdf9a39417e5f1072745c87 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Fri, 17 Jul 2026 16:41:38 +0300 Subject: [PATCH 3/3] fix(release): pin signer identities and yank release on failed verification Post-publish checks now require the actual publisher, not just any trusted one: Authenticode subject must contain "Flamingo AI, Inc.", codesign TeamIdentifier must be F7LDSU8JPJ (both extracted from signed openframe-oss-tenant artifacts). A verification failure now triggers a cleanup job that deletes the release and tag (gated on the release job having succeeded, which handles its own rollback). Windows asset download gets retries so a network blip can't yank a valid release. --- .github/workflows/release.yml | 46 +++++++++++++++++++++++++---- docs/development/release-signing.md | 4 +++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bff3dfd..1466fc38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -218,10 +218,11 @@ jobs: # --------------------------------------------------------------------------- # Post-publish signature verification: download the PUBLISHED assets on real # Windows/macOS runners and verify what users actually get — the OS trust - # store checks here can't run on the release runner. No secrets needed. - # A failure does NOT auto-yank the release (the release job's cleanup only - # covers its own failure): a bad signature at this point means investigate - # and delete the release manually. + # store checks here can't run on the release runner. No secrets needed: the + # pinned identities below are public facts, visible in any signed binary. + # A verification failure triggers cleanup-on-failed-verification, which + # yanks the release and tag (same philosophy as the release job's own + # cleanup: never leave a bad release visible as latest). verify-windows-signature: name: "Verify: Windows Authenticode" needs: [version, release] @@ -236,7 +237,7 @@ jobs: foreach ($arch in @("amd64", "arm64")) { $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:VERSION/openframe-cli_windows_$arch.zip" Write-Host "Verifying $url" - Invoke-WebRequest -Uri $url -OutFile "openframe_$arch.zip" + Invoke-WebRequest -Uri $url -OutFile "openframe_$arch.zip" -MaximumRetryCount 5 -RetryIntervalSec 5 Expand-Archive "openframe_$arch.zip" -DestinationPath "extracted_$arch" $sig = Get-AuthenticodeSignature -FilePath "extracted_$arch/openframe.exe" @@ -249,6 +250,14 @@ jobs: Write-Error "Signature verification failed for ${arch}: $($sig.Status) — $($sig.StatusMessage)" exit 1 } + # "Valid" only proves SOME trusted publisher signed it — pin ours. + # Subject of the Azure Trusted Signing cert (the verified legal + # entity; stable across the short-lived certs the service issues): + # CN=Flamingo AI, Inc., O=Flamingo AI, Inc., L=Miami, S=Florida, C=US + if ($sig.SignerCertificate.Subject -notlike "*Flamingo AI, Inc.*") { + Write-Error "Unexpected signer for ${arch}: $($sig.SignerCertificate.Subject)" + exit 1 + } if (-not $sig.TimeStamperCertificate) { Write-Error "Signature for ${arch} is not timestamped" exit 1 @@ -282,9 +291,36 @@ jobs: echo "::error::${arch} binary is not signed with a Developer ID Application certificate" exit 1 fi + # The Authority check only proves the certificate class — pin the + # signer to our Apple team (Flamingo AI, Inc.). + if ! echo "$info" | grep -q "TeamIdentifier=F7LDSU8JPJ"; then + echo "::error::${arch} binary signed by an unexpected team: $(echo "$info" | grep TeamIdentifier || echo 'TeamIdentifier missing')" + exit 1 + fi # Notarization check is best-effort: spctl needs quarantine context # and the ticket can lag right after publication (bare binaries # can't be stapled), so log the assessment without failing. spctl --assess --type open --context context:primary-signature --verbose "$bin" || true done echo "All macOS signatures verified" + + # A bad signature must not stay visible as `latest`. The release job's own + # cleanup only covers its own failure, so failed verification yanks the + # release + tag here. Gated on release success: if the release job failed it + # already rolled itself back (verification is then skipped anyway). + cleanup-on-failed-verification: + name: "Cleanup: yank release on failed verification" + needs: [version, release, verify-windows-signature, verify-macos-signature] + if: failure() && needs.release.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Delete release and tag + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "===================================================================" + echo "=== CLEANUP: yanking release ${VERSION} after failed verification" + echo "===================================================================" + gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${VERSION}" && echo "deleted tag ${VERSION}" || echo "no tag ${VERSION} to delete" diff --git a/docs/development/release-signing.md b/docs/development/release-signing.md index 9e1c0c8c..6cd39990 100644 --- a/docs/development/release-signing.md +++ b/docs/development/release-signing.md @@ -63,6 +63,10 @@ Two layers, neither needing certificates locally: assets on real Windows/macOS runners and verify them against the OS trust stores (`Get-AuthenticodeSignature` incl. timestamp; `codesign --verify` + Developer ID authority check, best-effort `spctl` notarization assessment). + Both checks pin the signer identity — Authenticode subject + `Flamingo AI, Inc.`, Apple `TeamIdentifier=F7LDSU8JPJ` — so a binary signed + by *some* trusted-but-wrong publisher still fails. On verification failure + the `cleanup-on-failed-verification` job yanks the release and tag. ## Verifying a released binary